7 Commits

Author SHA1 Message Date
f1fc5eafc3 Merge pull request 'dev' (#47) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 3m57s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #47
2026-08-12 15:25:19 +02:00
42398dad21 Merge pull request 'dev' (#46) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m47s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #46
2026-07-17 14:00:12 +02:00
d563d81584 Merge pull request 'Füge Funktion zur Umwandlung von Saison-Slugs in Labels hinzu und aktualisiere PDF-Daten mit saisonalen Informationen' (#45) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 1m59s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #45
2026-07-15 11:38:20 +02:00
380d0a8332 Merge pull request 'dev' (#44) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m41s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #44
2026-07-15 11:12:16 +02:00
301bd7acbd Merge pull request 'dev' (#43) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m44s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #43
2026-06-23 23:50:21 +02:00
05e25aa3d1 Merge pull request 'dev' (#42) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m15s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #42
2026-06-16 14:09:23 +02:00
ecae88af78 Merge pull request 'dev' (#41) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 3m0s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #41
2026-06-10 16:49:07 +02:00
21 changed files with 130 additions and 214 deletions

View File

@@ -13,16 +13,7 @@ jobs:
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with: with:
clean: true clean: true
# Der Analyse-Workflow benötigt keine Historie. Ein flacher Checkout fetch-depth: 0
# verhindert, dass der Runner das große Repository-Pack vollständig lädt.
fetch-depth: 1
# Der Web-Workflow braucht keine Android-Artefakte. Insbesondere liegt
# dort ein großer Heap-Dump im aktuellen Commit, der auch bei einem
# flachen Checkout übertragen würde und den Runner-Checkout abbricht.
sparse-checkout: |
/*
!/android-app/
sparse-checkout-cone-mode: false
- name: Ensure clean workspace - name: Ensure clean workspace
run: | run: |

2
.gitignore vendored
View File

@@ -94,8 +94,6 @@ dist
/android-app/**/build/ /android-app/**/build/
/android-app/local.properties /android-app/local.properties
/android-app/gradle-local.properties /android-app/gradle-local.properties
# JVM Heap-Dumps sind lokale Diagnoseartefakte und dürfen nie ins Repository.
*.hprof
# Build output (but keep production data!) # Build output (but keep production data!)
.output .output

BIN
android-app/java_pid672503.hprof Executable file

Binary file not shown.

View File

@@ -1,39 +1,39 @@
<template> <template>
<footer class="fixed bottom-0 left-0 right-0 z-40 border-t border-gray-200/90 bg-white/95 shadow-[0_-5px_18px_rgba(24,24,27,0.07)] backdrop-blur-sm"> <footer class="fixed bottom-0 left-0 right-0 z-40 bg-gray-900 border-t border-gray-800 shadow-2xl">
<div class="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 py-2.5 sm:py-3"> <div class="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 py-2 sm:py-3">
<div class="flex flex-col sm:flex-row justify-between items-center gap-y-1 sm:gap-y-0"> <div class="flex flex-col sm:flex-row justify-between items-center gap-y-1 sm:gap-y-0">
<p class="text-xs sm:text-sm text-gray-600 whitespace-nowrap"> <p class="text-xs sm:text-sm text-gray-400 whitespace-nowrap">
© {{ currentYear }} Harheimer TC 1954 e.V. © {{ currentYear }} Harheimer TC 1954 e.V.
</p> </p>
<div class="flex flex-wrap justify-center items-center gap-x-4 gap-y-1 text-xs sm:text-sm relative"> <div class="flex flex-wrap justify-center items-center gap-x-4 gap-y-1 text-xs sm:text-sm relative">
<span <span
v-if="isLoggedIn && appVersion" v-if="isLoggedIn && appVersion"
class="text-xs text-gray-500" class="text-xs text-gray-600"
title="Version" title="Version"
> >
v{{ appVersion }} v{{ appVersion }}
</span> </span>
<NuxtLink <NuxtLink
to="/impressum" to="/impressum"
class="text-gray-700 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 rounded-sm transition-colors" class="text-gray-400 hover:text-primary-400 transition-colors"
> >
Impressum Impressum
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/datenschutz" to="/datenschutz"
class="text-gray-700 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 rounded-sm transition-colors" class="text-gray-400 hover:text-primary-400 transition-colors"
> >
Datenschutz Datenschutz
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/konto-loeschen" to="/konto-loeschen"
class="text-gray-700 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 rounded-sm transition-colors" class="text-gray-400 hover:text-primary-400 transition-colors"
> >
Konto loeschen Konto loeschen
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/kontakt" to="/kontakt"
class="text-gray-700 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 rounded-sm transition-colors" class="text-gray-400 hover:text-primary-400 transition-colors"
> >
Kontakt Kontakt
</NuxtLink> </NuxtLink>
@@ -41,7 +41,7 @@
<!-- Login/Logout --> <!-- Login/Logout -->
<template v-if="isLoggedIn"> <template v-if="isLoggedIn">
<button <button
class="flex items-center space-x-1 rounded-sm text-gray-700 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 transition-colors" class="flex items-center space-x-1 text-gray-400 hover:text-primary-400 transition-colors"
@click="handleLogout" @click="handleLogout"
> >
<User :size="16" /> <User :size="16" />
@@ -53,7 +53,7 @@
class="relative" class="relative"
> >
<button <button
class="flex items-center space-x-1 rounded-sm text-gray-700 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 transition-colors" class="flex items-center space-x-1 text-gray-400 hover:text-primary-400 transition-colors"
@click="toggleMemberMenu" @click="toggleMemberMenu"
> >
<User :size="16" /> <User :size="16" />
@@ -75,25 +75,25 @@
> >
<div <div
v-if="isMemberMenuOpen" v-if="isMemberMenuOpen"
class="absolute bottom-full right-0 mb-2 w-48 overflow-hidden rounded-md border border-gray-200 bg-white shadow-[0_12px_28px_rgba(24,24,27,0.14)] ring-1 ring-black/5" class="absolute bottom-full right-0 mb-2 w-48 bg-gray-800 border border-gray-700 rounded-lg shadow-xl overflow-hidden"
> >
<NuxtLink <NuxtLink
to="/login" to="/login"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-primary-50 hover:text-primary-800 focus-visible:outline-none focus-visible:bg-primary-50 focus-visible:text-primary-800 transition-colors" class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
@click="isMemberMenuOpen = false" @click="isMemberMenuOpen = false"
> >
Anmelden Anmelden
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/registrieren" to="/registrieren"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-primary-50 hover:text-primary-800 focus-visible:outline-none focus-visible:bg-primary-50 focus-visible:text-primary-800 transition-colors" class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
@click="isMemberMenuOpen = false" @click="isMemberMenuOpen = false"
> >
Registrieren Registrieren
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/passwort-vergessen" to="/passwort-vergessen"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-primary-50 hover:text-primary-800 focus-visible:outline-none focus-visible:bg-primary-50 focus-visible:text-primary-800 transition-colors" class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
@click="isMemberMenuOpen = false" @click="isMemberMenuOpen = false"
> >
Passwort vergessen Passwort vergessen

View File

@@ -58,9 +58,7 @@
</p> </p>
</div> </div>
<p class="text-sm text-gray-800"> <p class="text-sm text-gray-800">
{{ formatMatchTeamName(game.HeimMannschaft, game.HeimMannschaftAltersklasse, game.Altersklasse) }} {{ game.HeimMannschaft }} vs {{ game.GastMannschaft }}
vs
{{ formatMatchTeamName(game.GastMannschaft, game.GastMannschaftAltersklasse, game.Altersklasse) }}
</p> </p>
</div> </div>
</div> </div>
@@ -71,7 +69,6 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { formatTeamDisplayName } from '~/utils/team-display'
const props = defineProps({ const props = defineProps({
season: { season: {
@@ -94,13 +91,10 @@ const games = ref([])
const widgetTitle = computed(() => { const widgetTitle = computed(() => {
if (!props.teamName) return 'Mannschaft' if (!props.teamName) return 'Mannschaft'
return formatTeamDisplayName(props.teamName, props.teamAgeGroup) const youth = String(props.teamAgeGroup || '').toLowerCase().includes('jugend')
return youth ? `(J) ${props.teamName}` : props.teamName
}) })
function formatMatchTeamName(teamName, teamAgeGroup, competitionAgeGroup) {
return formatTeamDisplayName(teamName, teamAgeGroup, competitionAgeGroup) || '-'
}
const seasonLabel = computed(() => { const seasonLabel = computed(() => {
const match = String(props.season || '').match(/^(\d{2})--(\d{2})$/) const match = String(props.season || '').match(/^(\d{2})--(\d{2})$/)
if (!match) return props.season || '-' if (!match) return props.season || '-'

View File

@@ -5,11 +5,11 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full">
<div class="flex flex-col justify-between h-full py-2"> <div class="flex flex-col justify-between h-full py-2">
<!-- Hauptmenü --> <!-- Hauptmenü -->
<div class="flex min-w-0 items-center gap-3"> <div class="flex justify-between items-center">
<!-- Logo --> <!-- Logo -->
<NuxtLink <NuxtLink
to="/" to="/"
class="flex shrink-0 items-center space-x-3 origin-left hover:scale-[1.02] transition-transform" class="flex items-center space-x-3 hover:scale-105 transition-transform"
> >
<img <img
src="~/assets/images/logos/Harheimer TC.svg" src="~/assets/images/logos/Harheimer TC.svg"
@@ -23,12 +23,9 @@
</div> </div>
</NuxtLink> </NuxtLink>
<div class="flex min-w-0 flex-1 flex-col"> <div style="display:flex;flex-direction:column;">
<!-- Desktop Navigation --> <!-- Desktop Navigation -->
<div <div class="hidden lg:flex items-center space-x-1">
class="hidden min-w-0 max-w-full lg:flex lg:items-center lg:gap-1 lg:overflow-x-auto lg:whitespace-nowrap [scrollbar-color:theme(colors.primary.700)_transparent] [scrollbar-width:thin]"
aria-label="Hauptmenü – horizontal scrollbar"
>
<NuxtLink <NuxtLink
to="/" to="/"
class="px-4 py-2 text-gray-300 hover:text-white font-medium transition-all rounded-lg hover:bg-primary-700/50" class="px-4 py-2 text-gray-300 hover:text-white font-medium transition-all rounded-lg hover:bg-primary-700/50"
@@ -114,10 +111,10 @@
</NuxtLink> </NuxtLink>
</div> </div>
<div class="hidden min-w-0 flex-1 lg:flex items-center h-6 border-t border-primary-700/20"> <div class="hidden lg:flex items-center h-6 border-t border-primary-700/20">
<div <div
v-if="currentSubmenu" v-if="currentSubmenu"
:class="currentSubmenu === 'mannschaften' ? 'relative min-w-0 flex-1' : 'flex min-w-0 items-center gap-1 overflow-x-auto whitespace-nowrap'" class="flex items-center space-x-1"
> >
<!-- Newsletter Submenu --> <!-- Newsletter Submenu -->
<template v-if="currentSubmenu === 'newsletter'"> <template v-if="currentSubmenu === 'newsletter'">
@@ -191,69 +188,41 @@
<!-- Mannschaften Submenu --> <!-- Mannschaften Submenu -->
<template v-if="currentSubmenu === 'mannschaften'"> <template v-if="currentSubmenu === 'mannschaften'">
<div <NuxtLink
ref="mannschaftenSubmenuRail" to="/mannschaften"
class="flex min-w-0 items-center gap-1 overflow-x-auto whitespace-nowrap scroll-smooth px-7 [scrollbar-color:theme(colors.primary.700)_transparent] [scrollbar-width:thin]" class="px-2.5 py-1 text-xs font-semibold text-white hover:bg-primary-700/50 rounded transition-all"
tabindex="0" active-class="bg-primary-600"
aria-label="Mannschaften-Untermenü – horizontal scrollbar" >
@scroll.passive="updateMannschaftenSubmenuControls" Übersicht
</NuxtLink>
<div class="h-3 w-px bg-primary-700" />
<template
v-for="mannschaft in mannschaften"
:key="mannschaft.slug"
> >
<NuxtLink <NuxtLink
to="/mannschaften" :to="`/mannschaften/${mannschaft.slug}`"
class="shrink-0 px-2.5 py-1 text-xs font-semibold text-white hover:bg-primary-700/50 rounded transition-all" class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="bg-primary-600"
>
Übersicht
</NuxtLink>
<div class="h-3 w-px shrink-0 bg-primary-700" />
<template
v-for="mannschaft in mannschaften"
:key="mannschaft.slug"
>
<NuxtLink
:to="`/mannschaften/${mannschaft.slug}`"
class="shrink-0 whitespace-nowrap px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600"
>
{{ mannschaft.mannschaft }}
</NuxtLink>
</template>
<div class="h-3 w-px shrink-0 bg-primary-700" />
<NuxtLink
to="/mannschaften/spielplaene"
class="shrink-0 whitespace-nowrap px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600" active-class="text-white bg-primary-600"
> >
Spielpläne {{ mannschaft.mannschaft }}
</NuxtLink> </NuxtLink>
<NuxtLink </template>
to="/spielsysteme" <div class="h-3 w-px bg-primary-700" />
class="shrink-0 whitespace-nowrap px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all" <NuxtLink
active-class="text-white bg-primary-600" to="/mannschaften/spielplaene"
> class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
Spielsysteme active-class="text-white bg-primary-600"
</NuxtLink>
</div>
<button
v-if="showMannschaftenSubmenuLeftControl"
type="button"
class="absolute inset-y-0 left-0 z-10 flex w-9 items-center justify-start bg-gradient-to-r from-primary-900 via-primary-900/90 to-transparent pl-1 text-primary-300 transition-colors hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-300"
aria-label="Vorherige Mannschaften anzeigen"
@pointerdown.stop
@click.stop.prevent="scrollMannschaftenSubmenuLeft"
> >
‹ Spielpläne
</button> </NuxtLink>
<button <NuxtLink
v-if="showMannschaftenSubmenuRightControl" to="/spielsysteme"
type="button" class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
class="absolute inset-y-0 right-0 z-10 flex w-9 items-center justify-end bg-gradient-to-l from-primary-900 via-primary-900/90 to-transparent pr-1 text-primary-300 transition-colors hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-300" active-class="text-white bg-primary-600"
aria-label="Weitere Mannschaften anzeigen"
@pointerdown.stop
@click.stop.prevent="scrollMannschaftenSubmenuRight"
> >
› Spielsysteme
</button> </NuxtLink>
</template> </template>
<!-- Training Submenu --> <!-- Training Submenu -->
@@ -884,7 +853,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from 'vue' import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { Menu, X, ChevronDown } from 'lucide-vue-next' import { Menu, X, ChevronDown } from 'lucide-vue-next'
@@ -892,9 +861,6 @@ const route = useRoute()
const isMobileMenuOpen = ref(false) const isMobileMenuOpen = ref(false)
const mobileSubmenu = ref(null) const mobileSubmenu = ref(null)
const mannschaften = ref([]) const mannschaften = ref([])
const mannschaftenSubmenuRail = ref(null)
const showMannschaftenSubmenuLeftControl = ref(false)
const showMannschaftenSubmenuRightControl = ref(false)
const hasGalleryImages = ref(false) const hasGalleryImages = ref(false)
const showCmsDropdown = ref(false) const showCmsDropdown = ref(false)
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -932,38 +898,6 @@ const toggleMobileSubmenu = (menu) => {
mobileSubmenu.value = mobileSubmenu.value === menu ? null : menu mobileSubmenu.value = mobileSubmenu.value === menu ? null : menu
} }
const updateMannschaftenSubmenuControls = () => {
const rail = mannschaftenSubmenuRail.value
if (!rail) return
const canScroll = rail.scrollWidth > rail.clientWidth + 1
const atStart = rail.scrollLeft <= 1
const atEnd = rail.scrollLeft + rail.clientWidth >= rail.scrollWidth - 1
showMannschaftenSubmenuLeftControl.value = canScroll && !atStart
showMannschaftenSubmenuRightControl.value = canScroll && !atEnd
}
const scrollMannschaftenSubmenuLeft = () => {
const rail = mannschaftenSubmenuRail.value
if (!rail) return
rail.scrollBy({
left: -Math.max(rail.clientWidth * 0.8, 160),
behavior: 'smooth'
})
}
const scrollMannschaftenSubmenuRight = () => {
const rail = mannschaftenSubmenuRail.value
if (!rail) return
rail.scrollBy({
left: Math.max(rail.clientWidth * 0.8, 160),
behavior: 'smooth'
})
}
const loadMannschaften = async () => { const loadMannschaften = async () => {
try { try {
const attempt = async () => { const attempt = async () => {
@@ -1048,23 +982,16 @@ onMounted(() => {
// Listen for global updates to mannschaften (e.g., CMS saved) // Listen for global updates to mannschaften (e.g., CMS saved)
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.addEventListener('mannschaften:changed', loadMannschaften) window.addEventListener('mannschaften:changed', loadMannschaften)
window.addEventListener('resize', updateMannschaftenSubmenuControls)
} }
nextTick(updateMannschaftenSubmenuControls)
}) })
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('click', handleDocumentClick) document.removeEventListener('click', handleDocumentClick)
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.removeEventListener('mannschaften:changed', loadMannschaften) window.removeEventListener('mannschaften:changed', loadMannschaften)
window.removeEventListener('resize', updateMannschaftenSubmenuControls)
} }
}) })
watch([currentSubmenu, mannschaften], () => {
nextTick(updateMannschaftenSubmenuControls)
}, { flush: 'post' })
const toggleSubmenu = (menu) => { const toggleSubmenu = (menu) => {
// Wenn wir schon im richtigen Bereich sind, nichts tun (Submenu bleibt offen) // Wenn wir schon im richtigen Bereich sind, nichts tun (Submenu bleibt offen)
// Wenn nicht, zur Hauptseite navigieren // Wenn nicht, zur Hauptseite navigieren

View File

@@ -146,7 +146,7 @@
Heim Heim
</p> </p>
<p class="font-semibold text-gray-900"> <p class="font-semibold text-gray-900">
{{ formatTeamName(game.HeimMannschaft, game.HeimMannschaftAltersklasse, game.Altersklasse) }} {{ formatTeamName(game.HeimMannschaft, game.HeimMannschaftAltersklasse) }}
</p> </p>
</div> </div>
<div class="text-center mx-4"> <div class="text-center mx-4">
@@ -159,7 +159,7 @@
Gast Gast
</p> </p>
<p class="font-semibold text-gray-900"> <p class="font-semibold text-gray-900">
{{ formatTeamName(game.GastMannschaft, game.GastMannschaftAltersklasse, game.Altersklasse) }} {{ formatTeamName(game.GastMannschaft, game.GastMannschaftAltersklasse) }}
</p> </p>
</div> </div>
</div> </div>
@@ -220,7 +220,6 @@
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { formatTeamDisplayName } from '~/utils/team-display'
const spielplanData = ref([]) const spielplanData = ref([])
const isLoading = ref(false) const isLoading = ref(false)
@@ -356,10 +355,17 @@ const formatTime = (terminString) => {
} }
} }
const formatTeamName = (teamName, ageGroup, competitionAgeGroup) => { const formatTeamName = (teamName, ageGroup) => {
if (!teamName) return 'Nicht angegeben' if (!teamName) return 'Nicht angegeben'
return formatTeamDisplayName(teamName, ageGroup, competitionAgeGroup) // Prüfe ob es Nachwuchs ist
const isNachwuchs = ageGroup && (
ageGroup.toLowerCase().includes('jugend') ||
teamName.toLowerCase().includes('jugend')
)
// Füge (J) für Nachwuchs hinzu
return isNachwuchs ? `(J) ${teamName}` : teamName
} }
const formatRunde = (runde) => { const formatRunde = (runde) => {

View File

@@ -282,11 +282,17 @@ install_dependencies_if_needed
# 4. Stop running apps before replacing build artifacts # 4. Stop running apps before replacing build artifacts
echo "" echo ""
echo "4. Stopping PM2 before replacing build artifacts..." echo "4. Stopping PM2 before replacing build artifacts..."
if command -v pm2 >/dev/null 2>&1 && pm2 describe harheimertc >/dev/null 2>&1; then if command -v pm2 >/dev/null 2>&1; then
pm2 stop harheimertc || true for instance_name in harheimertc harheimertc-3102; do
echo " ✓ harheimertc gestoppt" if pm2 describe "$instance_name" >/dev/null 2>&1; then
pm2 stop "$instance_name" || true
echo " ✓ $instance_name gestoppt"
else
echo " PM2-Prozess $instance_name läuft nicht"
fi
done
else else
echo " PM2-Prozess harheimertc läuft nicht oder PM2 ist nicht verfügbar" echo " PM2 ist nicht verfügbar"
fi fi
# 5. Remove old build (but keep data!) # 5. Remove old build (but keep data!)
@@ -553,14 +559,18 @@ restart_pm2_instance() {
fi fi
} }
# Starte/Neustarte die Produktionsinstanz # Starte/Neustarte beide Instanzen
INSTANCE_ERRORS=0 INSTANCE_ERRORS=0
if ! restart_pm2_instance "harheimertc"; then if ! restart_pm2_instance "harheimertc"; then
INSTANCE_ERRORS=$((INSTANCE_ERRORS + 1)) INSTANCE_ERRORS=$((INSTANCE_ERRORS + 1))
fi fi
# Prüfe, ob der Prozess läuft if ! restart_pm2_instance "harheimertc-3102"; then
INSTANCE_ERRORS=$((INSTANCE_ERRORS + 1))
fi
# Prüfe, ob beide Prozesse laufen
sleep 2 sleep 2
echo "" echo ""
echo " Checking PM2 instances status..." echo " Checking PM2 instances status..."
@@ -572,11 +582,19 @@ else
INSTANCE_ERRORS=$((INSTANCE_ERRORS + 1)) INSTANCE_ERRORS=$((INSTANCE_ERRORS + 1))
fi fi
if pm2 describe harheimertc-3102 | grep -q "online"; then
echo " ✓ PM2-Prozess 'harheimertc-3102' läuft (online)"
else
echo " WARNING: PM2-Prozess 'harheimertc-3102' ist nicht online. Prüfe Logs: pm2 logs harheimertc-3102"
INSTANCE_ERRORS=$((INSTANCE_ERRORS + 1))
fi
if [ "$INSTANCE_ERRORS" -gt 0 ]; then if [ "$INSTANCE_ERRORS" -gt 0 ]; then
echo "" echo ""
echo "WARNING: Einige PM2-Instanzen haben Probleme. Bitte manuell prüfen:" echo "WARNING: Einige PM2-Instanzen haben Probleme. Bitte manuell prüfen:"
echo " pm2 status" echo " pm2 status"
echo " pm2 logs harheimertc" echo " pm2 logs harheimertc"
echo " pm2 logs harheimertc-3102"
fi fi
echo "" echo ""
@@ -585,5 +603,8 @@ echo "The application is now running with the latest code and your production da
echo "" echo ""
echo "Useful commands:" echo "Useful commands:"
echo " pm2 logs harheimertc # View logs (Port 3100)" echo " pm2 logs harheimertc # View logs (Port 3100)"
echo " pm2 logs harheimertc-3102 # View logs (Port 3102)"
echo " pm2 status # View status" echo " pm2 status # View status"
echo " pm2 restart harheimertc # Restart instance on port 3100" echo " pm2 restart harheimertc # Restart instance on port 3100"
echo " pm2 restart harheimertc-3102 # Restart instance on port 3102"
echo " pm2 restart all # Restart all instances"

View File

@@ -567,10 +567,9 @@ restart_pm2_instance() {
# Starte/Neustarte Test-Instanz # Starte/Neustarte Test-Instanz
if ! restart_pm2_instance "harheimertc.test"; then if ! restart_pm2_instance "harheimertc.test"; then
echo "" echo ""
echo "ERROR: PM2-Instanz konnte nicht gestartet werden." echo "WARNING: PM2-Instanz konnte nicht gestartet werden. Bitte manuell prüfen:"
echo " pm2 status" echo " pm2 status"
echo " pm2 logs harheimertc.test" echo " pm2 logs harheimertc.test"
exit 1
fi fi
# Prüfe, ob der Prozess läuft # Prüfe, ob der Prozess läuft
@@ -581,16 +580,9 @@ echo " Checking PM2 instance status..."
if pm2 describe harheimertc.test | grep -q "online"; then if pm2 describe harheimertc.test | grep -q "online"; then
echo " ✓ PM2-Prozess 'harheimertc.test' läuft (online)" echo " ✓ PM2-Prozess 'harheimertc.test' läuft (online)"
else else
echo " ERROR: PM2-Prozess 'harheimertc.test' ist nicht online. Prüfe Logs: pm2 logs harheimertc.test" echo " WARNING: PM2-Prozess 'harheimertc.test' ist nicht online. Prüfe Logs: pm2 logs harheimertc.test"
exit 1
fi fi
if ! curl --fail --silent --show-error --max-time 15 http://127.0.0.1:3102/ >/dev/null; then
echo "ERROR: Test-Instanz antwortet nicht auf Port 3102."
exit 1
fi
echo " ✓ HTTP-Healthcheck auf Port 3102 erfolgreich"
echo "" echo ""
echo "=== Test-Instanz Deployment completed successfully! ===" echo "=== Test-Instanz Deployment completed successfully! ==="
echo "The test application is now running with the latest code and your test data preserved." echo "The test application is now running with the latest code and your test data preserved."

View File

@@ -61,6 +61,22 @@ module.exports = {
out_file: '/var/log/pm2/harheimertc-out.log', out_file: '/var/log/pm2/harheimertc-out.log',
log_file: '/var/log/pm2/harheimertc-combined.log', log_file: '/var/log/pm2/harheimertc-combined.log',
time: true time: true
},
{
name: 'harheimertc-3102',
// Zweite Instanz auf Port 3102
script: 'node',
args: '.output/server/index.mjs',
cwd: '/var/www/harheimertc',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '1G',
env: createEnv(3102),
error_file: '/var/log/pm2/harheimertc-3102-error.log',
out_file: '/var/log/pm2/harheimertc-3102-out.log',
log_file: '/var/log/pm2/harheimertc-3102-combined.log',
time: true
} }
] ]
} }

View File

@@ -58,7 +58,7 @@ module.exports = {
autorestart: true, autorestart: true,
watch: false, watch: false,
max_memory_restart: '1G', max_memory_restart: '1G',
env: createEnv(3102), env: createEnv(process.env.PORT || 3102),
error_file: '/var/log/pm2/harheimertc.test-error.log', error_file: '/var/log/pm2/harheimertc.test-error.log',
out_file: '/var/log/pm2/harheimertc.test-out.log', out_file: '/var/log/pm2/harheimertc.test-out.log',
log_file: '/var/log/pm2/harheimertc.test-combined.log', log_file: '/var/log/pm2/harheimertc.test-combined.log',

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "harheimertc-website", "name": "harheimertc-website",
"version": "1.8.7", "version": "1.8.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "harheimertc-website", "name": "harheimertc-website",
"version": "1.8.7", "version": "1.8.4",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@pinia/nuxt": "^0.11.2", "@pinia/nuxt": "^0.11.2",

View File

@@ -1,6 +1,6 @@
{ {
"name": "harheimertc-website", "name": "harheimertc-website",
"version": "1.8.8", "version": "1.8.5",
"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",
@@ -18,7 +18,7 @@
"test": "vitest run", "test": "vitest run",
"test:data-rotation": "vitest run tests/data-file-rotation.spec.ts", "test:data-rotation": "vitest run tests/data-file-rotation.spec.ts",
"check-security": "node scripts/verify-no-public-writes.js", "check-security": "node scripts/verify-no-public-writes.js",
"smoke-local": "BASE_URL=http://127.0.0.1:3100 node scripts/smoke-test.js", "smoke-local": "BASE_URL=http://127.0.0.1:3100 node scripts/smoke-tests.js",
"sync-public-data": "node scripts/sync-public-data.js", "sync-public-data": "node scripts/sync-public-data.js",
"data-backups:list": "node scripts/data-backup-restore.js list", "data-backups:list": "node scripts/data-backup-restore.js list",
"data-backups:restore": "node scripts/data-backup-restore.js restore", "data-backups:restore": "node scripts/data-backup-restore.js restore",

View File

@@ -362,7 +362,7 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { rowMatchesTeamFilter } from '~/utils/spielplan-filter.js' import { rowMatchesTeamFilter } from '../../utils/spielplan-filter.js'
const route = useRoute() const route = useRoute()

View File

@@ -360,7 +360,7 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { filterSpielplanRows, toApiTeamParam } from '~/utils/spielplan-filter.js' import { filterSpielplanRows, toApiTeamParam } from '../../utils/spielplan-filter.js'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()

View File

@@ -1,15 +1,18 @@
import { listSpielplanSeasons, readSpielplanData, validateSeasonSlug } from '../../utils/spielplan-data.js' import { listSpielplanSeasons, readSpielplanData, validateSeasonSlug } from '../../utils/spielplan-data.js'
import { formatTeamDisplayName } from '~/utils/team-display.js'
function teamLabel(teamName, teamAgeGroup, competitionAgeGroup) { function teamLabel(teamName, teamAgeGroup) {
return formatTeamDisplayName(teamName, teamAgeGroup, competitionAgeGroup) const name = String(teamName || '').trim()
const age = String(teamAgeGroup || '').trim()
if (!name) return ''
const isYouth = age.toLowerCase().includes('jugend') || name.toLowerCase().includes('jugend')
return isYouth ? `(J) ${name}` : name
} }
function extractHarheimerTeams(rows) { function extractHarheimerTeams(rows) {
const seen = new Set() const seen = new Set()
const teams = [] const teams = []
const addTeam = (teamName, teamAgeGroup, competitionAgeGroup) => { const addTeam = (teamName, teamAgeGroup) => {
const name = String(teamName || '').trim() const name = String(teamName || '').trim()
if (!name) return if (!name) return
const age = String(teamAgeGroup || '').trim() const age = String(teamAgeGroup || '').trim()
@@ -18,7 +21,7 @@ function extractHarheimerTeams(rows) {
seen.add(key) seen.add(key)
teams.push({ teams.push({
key, key,
label: teamLabel(name, age, competitionAgeGroup), label: teamLabel(name, age),
teamName: name, teamName: name,
teamAgeGroup: age teamAgeGroup: age
}) })
@@ -26,10 +29,10 @@ function extractHarheimerTeams(rows) {
for (const row of rows || []) { for (const row of rows || []) {
if (String(row.HeimVereinName || '').trim() === 'Harheimer TC') { if (String(row.HeimVereinName || '').trim() === 'Harheimer TC') {
addTeam(row.HeimMannschaft, row.HeimMannschaftAltersklasse, row.Altersklasse) addTeam(row.HeimMannschaft, row.HeimMannschaftAltersklasse)
} }
if (String(row.GastVereinName || '').trim() === 'Harheimer TC') { if (String(row.GastVereinName || '').trim() === 'Harheimer TC') {
addTeam(row.GastMannschaft, row.GastMannschaftAltersklasse, row.Altersklasse) addTeam(row.GastMannschaft, row.GastMannschaftAltersklasse)
} }
} }

View File

@@ -2,7 +2,7 @@ import fs from 'fs/promises'
import path from 'path' import path from 'path'
import { readSpielplanData, validateSeasonSlug } from '../../utils/spielplan-data.js' import { readSpielplanData, validateSeasonSlug } from '../../utils/spielplan-data.js'
import { info as loggerInfo, error as loggerError } from '../../utils/logger.js' import { info as loggerInfo, error as loggerError } from '../../utils/logger.js'
import { filterSpielplanRows } from '~/utils/spielplan-filter.js' import { filterSpielplanRows } from '../../../utils/spielplan-filter.js'
function seasonSlugToLabel(slug) { function seasonSlugToLabel(slug) {
const match = String(slug || '').match(/^(\d{2})--(\d{2})$/) const match = String(slug || '').match(/^(\d{2})--(\d{2})$/)

View File

@@ -83,7 +83,6 @@ describe('Auth API Endpoints', () => {
afterEach(() => { afterEach(() => {
delete process.env.NODE_ENV delete process.env.NODE_ENV
delete process.env.APP_ENV delete process.env.APP_ENV
delete process.env.DEBUG
delete process.env.NUXT_PUBLIC_BASE_URL delete process.env.NUXT_PUBLIC_BASE_URL
delete process.env.PASSWORD_RESET_TTL_MIN delete process.env.PASSWORD_RESET_TTL_MIN
}) })
@@ -283,7 +282,6 @@ describe('Auth API Endpoints', () => {
it('benachrichtigt in Testumgebung nicht die Vorstand-Empfänger', async () => { it('benachrichtigt in Testumgebung nicht die Vorstand-Empfänger', async () => {
process.env.NODE_ENV = 'production' process.env.NODE_ENV = 'production'
process.env.APP_ENV = 'test' process.env.APP_ENV = 'test'
delete process.env.DEBUG
const event = createEvent() const event = createEvent()
mockSuccessReadBody({ mockSuccessReadBody({

View File

@@ -1,30 +0,0 @@
/**
* Makes youth teams identifiable while retaining the team name supplied by
* click-TT. Age groups occur in a few forms, for example "Jugend 13",
* "Jugend 13 1. Kreisklasse", and "J13". Some imports provide the age
* only for the whole fixture, therefore `competitionAgeGroup` is considered
* as a fallback.
*/
export function youthTeamCode(teamAgeGroup, teamName = '', competitionAgeGroup = '') {
const ageGroup = [teamAgeGroup, competitionAgeGroup]
.map(value => String(value || '').trim())
.filter(Boolean)
.join(' ')
const name = String(teamName || '').trim()
const codeMatch = `${ageGroup} ${name}`.match(/\b(?:jugend\s*|j\s*\(?)(\d{1,2})\b/i)
if (codeMatch) return `J${codeMatch[1]}`
if (/\bjugend\b/i.test(ageGroup) || /\bjugend\b/i.test(name)) return 'J'
return ''
}
export function formatTeamDisplayName(teamName, teamAgeGroup, competitionAgeGroup = '') {
const rawName = String(teamName || '').trim()
if (!rawName) return ''
const youthCode = youthTeamCode(teamAgeGroup, rawName, competitionAgeGroup)
const name = youthCode
? rawName.replace(/\s*\(J\d{1,2}\)\s*$/i, '').trim()
: rawName
return youthCode ? `(${youthCode}) ${name}` : name
}