dev #48

Merged
admin merged 4 commits from dev into main 2026-08-26 11:38:34 +02:00
15 changed files with 123 additions and 109 deletions

View File

@@ -13,7 +13,9 @@ jobs:
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with: with:
clean: true clean: true
fetch-depth: 0 # Der Analyse-Workflow benötigt keine Historie. Ein flacher Checkout
# verhindert, dass der Runner das große Repository-Pack vollständig lädt.
fetch-depth: 1
- name: Ensure clean workspace - name: Ensure clean workspace
run: | run: |

View File

@@ -58,7 +58,9 @@
</p> </p>
</div> </div>
<p class="text-sm text-gray-800"> <p class="text-sm text-gray-800">
{{ game.HeimMannschaft }} vs {{ game.GastMannschaft }} {{ formatMatchTeamName(game.HeimMannschaft, game.HeimMannschaftAltersklasse, game.Altersklasse) }}
vs
{{ formatMatchTeamName(game.GastMannschaft, game.GastMannschaftAltersklasse, game.Altersklasse) }}
</p> </p>
</div> </div>
</div> </div>
@@ -69,6 +71,7 @@
<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: {
@@ -91,10 +94,13 @@ const games = ref([])
const widgetTitle = computed(() => { const widgetTitle = computed(() => {
if (!props.teamName) return 'Mannschaft' if (!props.teamName) return 'Mannschaft'
const youth = String(props.teamAgeGroup || '').toLowerCase().includes('jugend') return formatTeamDisplayName(props.teamName, props.teamAgeGroup)
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 || '-'
@@ -184,4 +190,4 @@ watch(
}, },
{ immediate: true } { immediate: true }
) )
</script> </script>

View File

@@ -111,10 +111,10 @@
</NuxtLink> </NuxtLink>
</div> </div>
<div class="hidden lg:flex items-center h-6 border-t border-primary-700/20"> <div class="hidden lg:flex items-center h-6 min-w-0 border-t border-primary-700/20">
<div <div
v-if="currentSubmenu" v-if="currentSubmenu"
class="flex items-center space-x-1" :class="currentSubmenu === 'mannschaften' ? 'relative min-w-0 flex-1' : 'flex items-center space-x-1'"
> >
<!-- Newsletter Submenu --> <!-- Newsletter Submenu -->
<template v-if="currentSubmenu === 'newsletter'"> <template v-if="currentSubmenu === 'newsletter'">
@@ -188,41 +188,53 @@
<!-- Mannschaften Submenu --> <!-- Mannschaften Submenu -->
<template v-if="currentSubmenu === 'mannschaften'"> <template v-if="currentSubmenu === 'mannschaften'">
<NuxtLink <div
to="/mannschaften" class="flex min-w-0 items-center gap-1 overflow-x-auto whitespace-nowrap scroll-smooth pr-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"
>
Übersicht
</NuxtLink>
<div class="h-3 w-px bg-primary-700" />
<template
v-for="mannschaft in mannschaften"
:key="mannschaft.slug"
> >
<NuxtLink <NuxtLink
:to="`/mannschaften/${mannschaft.slug}`" to="/mannschaften"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all" class="shrink-0 px-2.5 py-1 text-xs font-semibold 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 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"
> >
{{ mannschaft.mannschaft }} Spielpläne
</NuxtLink> </NuxtLink>
</template> <NuxtLink
<div class="h-3 w-px bg-primary-700" /> to="/spielsysteme"
<NuxtLink class="shrink-0 px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
to="/mannschaften/spielplaene" active-class="text-white bg-primary-600"
class="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" Spielsysteme
</NuxtLink>
</div>
<span
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 right-0 flex w-9 items-center justify-end bg-gradient-to-l from-primary-900 via-primary-900/90 to-transparent text-primary-300"
> >
Spielpläne
</NuxtLink> </span>
<NuxtLink
to="/spielsysteme"
class="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"
>
Spielsysteme
</NuxtLink>
</template> </template>
<!-- Training Submenu --> <!-- Training Submenu -->

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) }} {{ formatTeamName(game.HeimMannschaft, game.HeimMannschaftAltersklasse, game.Altersklasse) }}
</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) }} {{ formatTeamName(game.GastMannschaft, game.GastMannschaftAltersklasse, game.Altersklasse) }}
</p> </p>
</div> </div>
</div> </div>
@@ -220,6 +220,7 @@
<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)
@@ -355,17 +356,10 @@ const formatTime = (terminString) => {
} }
} }
const formatTeamName = (teamName, ageGroup) => { const formatTeamName = (teamName, ageGroup, competitionAgeGroup) => {
if (!teamName) return 'Nicht angegeben' if (!teamName) return 'Nicht angegeben'
// Prüfe ob es Nachwuchs ist return formatTeamDisplayName(teamName, ageGroup, competitionAgeGroup)
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,17 +282,11 @@ 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; then if command -v pm2 >/dev/null 2>&1 && pm2 describe harheimertc >/dev/null 2>&1; then
for instance_name in harheimertc harheimertc-3102; do pm2 stop harheimertc || true
if pm2 describe "$instance_name" >/dev/null 2>&1; then echo " ✓ harheimertc gestoppt"
pm2 stop "$instance_name" || true
echo "$instance_name gestoppt"
else
echo " PM2-Prozess $instance_name läuft nicht"
fi
done
else else
echo " PM2 ist nicht verfügbar" echo " PM2-Prozess harheimertc läuft nicht oder PM2 ist nicht verfügbar"
fi fi
# 5. Remove old build (but keep data!) # 5. Remove old build (but keep data!)
@@ -559,18 +553,14 @@ restart_pm2_instance() {
fi fi
} }
# Starte/Neustarte beide Instanzen # Starte/Neustarte die Produktionsinstanz
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
if ! restart_pm2_instance "harheimertc-3102"; then # Prüfe, ob der Prozess läuft
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..."
@@ -582,19 +572,11 @@ 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 ""
@@ -603,8 +585,5 @@ 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,9 +567,10 @@ 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 "WARNING: PM2-Instanz konnte nicht gestartet werden. Bitte manuell prüfen:" echo "ERROR: PM2-Instanz konnte nicht gestartet werden."
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
@@ -580,9 +581,16 @@ 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 " WARNING: PM2-Prozess 'harheimertc.test' ist nicht online. Prüfe Logs: pm2 logs harheimertc.test" echo " ERROR: 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,22 +61,6 @@ 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(process.env.PORT || 3102), env: createEnv(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.4", "version": "1.8.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "harheimertc-website", "name": "harheimertc-website",
"version": "1.8.4", "version": "1.8.6",
"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.5", "version": "1.8.6",
"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-tests.js", "smoke-local": "BASE_URL=http://127.0.0.1:3100 node scripts/smoke-test.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

@@ -1,18 +1,15 @@
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) { function teamLabel(teamName, teamAgeGroup, competitionAgeGroup) {
const name = String(teamName || '').trim() return formatTeamDisplayName(teamName, teamAgeGroup, competitionAgeGroup)
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) => { const addTeam = (teamName, teamAgeGroup, competitionAgeGroup) => {
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()
@@ -21,7 +18,7 @@ function extractHarheimerTeams(rows) {
seen.add(key) seen.add(key)
teams.push({ teams.push({
key, key,
label: teamLabel(name, age), label: teamLabel(name, age, competitionAgeGroup),
teamName: name, teamName: name,
teamAgeGroup: age teamAgeGroup: age
}) })
@@ -29,10 +26,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) addTeam(row.HeimMannschaft, row.HeimMannschaftAltersklasse, row.Altersklasse)
} }
if (String(row.GastVereinName || '').trim() === 'Harheimer TC') { if (String(row.GastVereinName || '').trim() === 'Harheimer TC') {
addTeam(row.GastMannschaft, row.GastMannschaftAltersklasse) addTeam(row.GastMannschaft, row.GastMannschaftAltersklasse, row.Altersklasse)
} }
} }
@@ -58,4 +55,4 @@ export default defineEventHandler(async (event) => {
seasons, seasons,
teams: extractHarheimerTeams(dataResult.data) teams: extractHarheimerTeams(dataResult.data)
} }
}) })

View File

@@ -83,6 +83,7 @@ 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
}) })
@@ -282,6 +283,7 @@ 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({

30
utils/team-display.js Normal file
View File

@@ -0,0 +1,30 @@
/**
* 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
}