Refactor backend to enhance MyTischtennis integration. Update package.json to change main entry point to server.js. Modify server.js to improve scheduler service logging. Add new fields to ClubTeam, League, Match, and Member models for MyTischtennis data. Update routes to include new MyTischtennis URL parsing and configuration endpoints. Enhance services for fetching team data and scheduling match results. Improve frontend components for MyTischtennis URL configuration and display match results with scores.

This commit is contained in:
Torsten Schulz (local)
2025-10-14 21:58:21 +02:00
parent 993e12d4a5
commit 1517d83f6c
31 changed files with 4538 additions and 56 deletions

View File

View File

@@ -0,0 +1,212 @@
# MyTischtennis Automatischer Datenabruf
## Übersicht
Dieses System ermöglicht den automatischen Abruf von Spielergebnissen und Statistiken von myTischtennis.de.
## Scheduler
### 6:00 Uhr - Rating Updates
- **Service:** `autoUpdateRatingsService.js`
- **Funktion:** Aktualisiert TTR/QTTR-Werte für Spieler
- **TODO:** Implementierung der eigentlichen Rating-Update-Logik
### 6:30 Uhr - Spielergebnisse
- **Service:** `autoFetchMatchResultsService.js`
- **Funktion:** Ruft Spielerbilanzen für konfigurierte Teams ab
- **Status:** ✅ Grundlegende Implementierung fertig
## Benötigte Konfiguration
### 1. MyTischtennis-Account
- Account muss in den MyTischtennis-Settings verknüpft sein
- Checkbox "Automatische Updates" aktivieren
- Passwort speichern (erforderlich für automatische Re-Authentifizierung)
### 2. League-Konfiguration
Für jede Liga müssen folgende Felder ausgefüllt werden:
```sql
UPDATE league SET
my_tischtennis_group_id = '504417', -- Group ID von myTischtennis
association = 'HeTTV', -- Verband (z.B. HeTTV, DTTB)
groupname = '1.Kreisklasse' -- Gruppenname für URL
WHERE id = 1;
```
**Beispiel-URL:**
```
https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/...
^^^^^ ^^^^^^^^^^^^^^ ^^^^^^
association groupname group_id
```
### 3. Team-Konfiguration
Für jedes Team muss die myTischtennis Team-ID gesetzt werden:
```sql
UPDATE club_team SET
my_tischtennis_team_id = '2995094' -- Team ID von myTischtennis
WHERE id = 1;
```
**Beispiel-URL:**
```
.../mannschaft/2995094/Harheimer_TC_(J11)/spielerbilanzen/gesamt
^^^^^^^
team_id
```
### 4. Spieler-Zuordnung (Optional)
Spieler werden automatisch anhand des Namens zugeordnet. Für genauere Zuordnung kann die myTischtennis Player-ID gesetzt werden:
```sql
UPDATE member SET
my_tischtennis_player_id = 'NU2705037' -- Player ID von myTischtennis
WHERE id = 1;
```
## Migrationen
Folgende Migrationen müssen ausgeführt werden:
```bash
# 1. MyTischtennis Auto-Update-Felder
mysql -u root -p trainingstagebuch < backend/migrations/add_auto_update_ratings_to_my_tischtennis.sql
# 2. MyTischtennis Update-History-Tabelle
mysql -u root -p trainingstagebuch < backend/migrations/create_my_tischtennis_update_history.sql
# 3. League MyTischtennis-Felder
mysql -u root -p trainingstagebuch < backend/migrations/add_mytischtennis_fields_to_league.sql
# 4. Team MyTischtennis-ID
mysql -u root -p trainingstagebuch < backend/migrations/add_mytischtennis_team_id_to_club_team.sql
# 5. Member MyTischtennis Player-ID
mysql -u root -p trainingstagebuch < backend/migrations/add_mytischtennis_player_id_to_member.sql
# 6. Match Result-Felder
mysql -u root -p trainingstagebuch < backend/migrations/add_match_result_fields.sql
```
## Abgerufene Daten
Von der myTischtennis API werden folgende Daten abgerufen:
### Einzelstatistiken
- Player ID, Vorname, Nachname
- Gewonnene/Verlorene Punkte
- Anzahl Spiele
- Detaillierte Statistiken nach Gegner-Position
### Doppelstatistiken
- Player IDs, Namen der beiden Spieler
- Gewonnene/Verlorene Punkte
- Anzahl Spiele
### Team-Informationen
- Teamname, Liga, Saison
- Gesamtpunkte (gewonnen/verloren)
- Doppel- und Einzelpunkte
## Implementierungsdetails
### Datenfluss
1. **Scheduler** (6:30 Uhr):
- `schedulerService.js` triggert `autoFetchMatchResultsService.executeAutomaticFetch()`
2. **Account-Verarbeitung**:
- Lädt alle MyTischtennis-Accounts mit `autoUpdateRatings = true`
- Prüft Session-Gültigkeit
- Re-Authentifizierung bei abgelaufener Session
3. **Team-Abfrage**:
- Lädt alle Teams mit konfigurierten myTischtennis-IDs
- Baut API-URL dynamisch zusammen
- Führt authentifizierten GET-Request durch
4. **Datenverarbeitung**:
- Parst JSON-Response
- Matched Spieler anhand von ID oder Name
- Speichert myTischtennis Player-ID bei Mitgliedern
- Loggt Statistiken
### Player-Matching-Algorithmus
```javascript
1. Suche nach myTischtennis Player-ID (exakte Übereinstimmung)
2. Falls nicht gefunden: Suche nach Name (case-insensitive)
3. Falls gefunden: Speichere myTischtennis Player-ID für zukünftige Abfragen
```
**Hinweis:** Da Namen verschlüsselt gespeichert werden, müssen für den Namens-Abgleich alle Members geladen und entschlüsselt werden. Dies ist bei großen Datenbanken ineffizient.
## TODO / Offene Punkte
### Noch zu implementieren:
1. **TTR/QTTR Updates** (6:00 Uhr Job):
- Endpoint für TTR/QTTR-Daten identifizieren
- Daten abrufen und in Member-Tabelle speichern
2. **Spielergebnis-Details**:
- Einzelne Matches mit Satzständen speichern
- Tabelle für Match-Historie erstellen
3. **History-Tabelle für Spielergebnis-Abrufe** (optional):
- Ähnlich zu `my_tischtennis_update_history`
- Speichert Erfolg/Fehler der Abrufe
4. **Benachrichtigungen** (optional):
- Email/Push bei neuen Ergebnissen
- Highlights für besondere Siege
5. **Performance-Optimierung**:
- Caching für Player-Matches
- Incremental Updates (nur neue Daten)
## Manueller Test
```javascript
// Im Node-Backend-Code oder über API-Endpoint:
import schedulerService from './services/schedulerService.js';
// Rating Updates manuell triggern
await schedulerService.triggerRatingUpdates();
// Spielergebnisse manuell abrufen
await schedulerService.triggerMatchResultsFetch();
```
## API-Dokumentation
### MyTischtennis Spielerbilanzen-Endpoint
**URL-Format:**
```
https://www.mytischtennis.de/click-tt/{association}/{season}/ligen/{groupname}/gruppe/{groupId}/mannschaft/{teamId}/{teamname}/spielerbilanzen/gesamt?_data=routes%2Fclick-tt%2B%2F%24association%2B%2F%24season%2B%2F%24type%2B%2F%28%24groupname%29.gruppe.%24urlid_.mannschaft.%24teamid.%24teamname%2B%2Fspielerbilanzen.%24filter
```
**Parameter:**
- `{association}`: Verband (z.B. "HeTTV")
- `{season}`: Saison im Format "25--26"
- `{groupname}`: Gruppenname URL-encoded (z.B. "1.Kreisklasse")
- `{groupId}`: Gruppen-ID (numerisch, z.B. "504417")
- `{teamId}`: Team-ID (numerisch, z.B. "2995094")
- `{teamname}`: Teamname URL-encoded mit Underscores (z.B. "Harheimer_TC_(J11)")
**Response:** JSON mit `data.balancesheet` Array
## Sicherheit
- ✅ Automatische Session-Verwaltung
- ✅ Re-Authentifizierung bei abgelaufenen Sessions
- ✅ Passwörter verschlüsselt gespeichert
- ✅ Fehlerbehandlung und Logging
- ✅ Graceful Degradation (einzelne Team-Fehler stoppen nicht den gesamten Prozess)

View File

@@ -0,0 +1,328 @@
# MyTischtennis URL Parser
## Übersicht
Der URL-Parser ermöglicht es, myTischtennis-Team-URLs automatisch zu parsen und die Konfiguration für automatische Datenabrufe vorzunehmen.
## Verwendung
### 1. URL Parsen
**Endpoint:** `POST /api/mytischtennis/parse-url`
**Request:**
```json
{
"url": "https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/spielerbilanzen/gesamt"
}
```
**Response:**
```json
{
"success": true,
"data": {
"association": "HeTTV",
"season": "25/26",
"type": "ligen",
"groupname": "1.Kreisklasse",
"groupId": "504417",
"teamId": "2995094",
"teamname": "Harheimer TC (J11)",
"originalUrl": "https://www.mytischtennis.de/click-tt/...",
"clubId": "43030",
"clubName": "Harheimer TC",
"teamName": "Jugend 11",
"leagueName": "Jugend 13 1. Kreisklasse",
"region": "Frankfurt",
"tableRank": 8,
"matchesWon": 0,
"matchesLost": 3
}
}
```
### 2. Team Automatisch Konfigurieren
**Endpoint:** `POST /api/mytischtennis/configure-team`
**Request:**
```json
{
"url": "https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/spielerbilanzen/gesamt",
"clubTeamId": 1,
"createLeague": false,
"createSeason": false
}
```
**Parameter:**
- `url` (required): Die myTischtennis-URL
- `clubTeamId` (required): Die ID des lokalen Club-Teams
- `createLeague` (optional): Wenn `true`, wird eine neue League erstellt
- `createSeason` (optional): Wenn `true`, wird eine neue Season erstellt
**Response:**
```json
{
"success": true,
"message": "Team configured successfully",
"data": {
"team": {
"id": 1,
"name": "Jugend 11",
"myTischtennisTeamId": "2995094"
},
"league": {
"id": 5,
"name": "Jugend 13 1. Kreisklasse",
"myTischtennisGroupId": "504417",
"association": "HeTTV",
"groupname": "1.Kreisklasse"
},
"season": {
"id": 2,
"name": "25/26"
},
"parsedData": { ... }
}
}
```
### 3. URL für Team Abrufen
**Endpoint:** `GET /api/mytischtennis/team-url/:teamId`
**Response:**
```json
{
"success": true,
"url": "https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer%20TC%20%28J11%29/spielerbilanzen/gesamt"
}
```
## URL-Format
### Unterstützte URL-Muster
```
https://www.mytischtennis.de/click-tt/{association}/{season}/{type}/{groupname}/gruppe/{groupId}/mannschaft/{teamId}/{teamname}/...
```
**Komponenten:**
- `{association}`: Verband (z.B. "HeTTV", "DTTB", "WestD")
- `{season}`: Saison im Format "YY--YY" (z.B. "25--26" für 2025/2026)
- `{type}`: Typ (meist "ligen")
- `{groupname}`: Gruppenname URL-encoded (z.B. "1.Kreisklasse", "Kreisliga")
- `{groupId}`: Numerische Gruppen-ID (z.B. "504417")
- `{teamId}`: Numerische Team-ID (z.B. "2995094")
- `{teamname}`: Teamname URL-encoded mit Underscores (z.B. "Harheimer_TC_(J11)")
### Beispiel-URLs
**Spielerbilanzen:**
```
https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/spielerbilanzen/gesamt
```
**Spielplan:**
```
https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/spielplan
```
**Tabelle:**
```
https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/tabelle
```
## Datenfluss
### Ohne MyTischtennis-Login
1. URL wird geparst
2. Nur URL-Komponenten werden extrahiert
3. Zusätzliche Daten (clubName, leagueName, etc.) sind nicht verfügbar
### Mit MyTischtennis-Login
1. URL wird geparst
2. API-Request an myTischtennis mit Authentication
3. Vollständige Team-Daten werden abgerufen
4. Alle Felder sind verfügbar
## Frontend-Integration
### Vue.js Beispiel
```javascript
<template>
<div>
<input
v-model="myTischtennisUrl"
placeholder="MyTischtennis URL einfügen..."
@blur="parseUrl"
/>
<div v-if="parsedData">
<h3>{{ parsedData.teamname }}</h3>
<p>Liga: {{ parsedData.leagueName }}</p>
<p>Verband: {{ parsedData.association }}</p>
<p>Tabelle: Platz {{ parsedData.tableRank }}</p>
<button @click="configureTeam">Team konfigurieren</button>
</div>
</div>
</template>
<script>
export default {
data() {
return {
myTischtennisUrl: '',
parsedData: null
};
},
methods: {
async parseUrl() {
if (!this.myTischtennisUrl) return;
try {
const response = await fetch('/api/mytischtennis/parse-url', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'userid': this.userId,
'authcode': this.authCode
},
body: JSON.stringify({
url: this.myTischtennisUrl
})
});
const result = await response.json();
this.parsedData = result.data;
} catch (error) {
console.error('Fehler beim Parsen:', error);
alert('URL konnte nicht geparst werden');
}
},
async configureTeam() {
try {
const response = await fetch('/api/mytischtennis/configure-team', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'userid': this.userId,
'authcode': this.authCode
},
body: JSON.stringify({
url: this.myTischtennisUrl,
clubTeamId: this.selectedTeamId,
createLeague: false,
createSeason: true
})
});
const result = await response.json();
if (result.success) {
alert('Team erfolgreich konfiguriert!');
}
} catch (error) {
console.error('Fehler bei Konfiguration:', error);
alert('Team konnte nicht konfiguriert werden');
}
}
}
};
</script>
```
## Workflow
### Empfohlener Workflow für Benutzer
1. **MyTischtennis-URL kopieren:**
- Auf myTischtennis.de zum Team navigieren
- URL aus Adresszeile kopieren
2. **URL in Trainingstagebuch einfügen:**
- Zu Team-Verwaltung navigieren
- URL einfügen
- Automatisches Parsen
3. **Konfiguration überprüfen:**
- Geparste Daten werden angezeigt
- Benutzer kann Daten überprüfen und bei Bedarf anpassen
4. **Team konfigurieren:**
- Auf "Konfigurieren" klicken
- System speichert alle benötigten IDs
- Automatischer Datenabruf ist ab sofort aktiv
## Fehlerbehandlung
### Häufige Fehler
**"Invalid myTischtennis URL format"**
- URL entspricht nicht dem erwarteten Format
- Lösung: Vollständige URL von der Spielerbilanzen-Seite kopieren
**"Season not found"**
- Saison existiert noch nicht in der Datenbank
- Lösung: `createSeason: true` setzen
**"Team has no league assigned"**
- Team hat keine verknüpfte Liga
- Lösung: `createLeague: true` setzen oder Liga manuell zuweisen
**"HTTP 401: Unauthorized"**
- MyTischtennis-Login abgelaufen oder nicht vorhanden
- Lösung: In MyTischtennis-Settings erneut anmelden
## Sicherheit
- ✅ Alle Endpoints erfordern Authentifizierung
- ✅ UserID wird aus Header-Parameter gelesen
- ✅ MyTischtennis-Credentials werden sicher gespeichert
- ✅ Keine sensiblen Daten in URLs
## Technische Details
### Service: `myTischtennisUrlParserService`
**Methoden:**
- `parseUrl(url)` - Parst URL und extrahiert Komponenten
- `fetchTeamData(parsedUrl, cookie, accessToken)` - Ruft zusätzliche Daten ab
- `getCompleteConfig(url, cookie, accessToken)` - Kombination aus Parsen + Abrufen
- `isValidTeamUrl(url)` - Validiert URL-Format
- `buildUrl(config)` - Baut URL aus Komponenten
### Controller: `myTischtennisUrlController`
**Endpoints:**
- `POST /api/mytischtennis/parse-url` - URL parsen
- `POST /api/mytischtennis/configure-team` - Team konfigurieren
- `GET /api/mytischtennis/team-url/:teamId` - URL abrufen
## Zukünftige Erweiterungen
### Geplante Features
1. **Bulk-Import:**
- Mehrere URLs gleichzeitig importieren
- Alle Teams einer Liga auf einmal konfigurieren
2. **Auto-Discovery:**
- Automatisches Finden aller Teams eines Vereins
- Vorschläge für ähnliche Teams
3. **Validierung:**
- Prüfung, ob Team bereits konfiguriert ist
- Warnung bei Duplikaten
4. **History:**
- Speichern der URL-Konfigurationen
- Versionierung bei Änderungen

View File

@@ -0,0 +1,394 @@
import myTischtennisUrlParserService from '../services/myTischtennisUrlParserService.js';
import myTischtennisService from '../services/myTischtennisService.js';
import autoFetchMatchResultsService from '../services/autoFetchMatchResultsService.js';
import ClubTeam from '../models/ClubTeam.js';
import League from '../models/League.js';
import Season from '../models/Season.js';
import User from '../models/User.js';
import HttpError from '../exceptions/HttpError.js';
import { devLog } from '../utils/logger.js';
class MyTischtennisUrlController {
/**
* Parse myTischtennis URL and return configuration data
* POST /api/mytischtennis/parse-url
* Body: { url: string }
*/
async parseUrl(req, res, next) {
try {
const { url } = req.body;
if (!url) {
throw new HttpError(400, 'URL is required');
}
// Validate URL
if (!myTischtennisUrlParserService.isValidTeamUrl(url)) {
throw new HttpError(400, 'Invalid myTischtennis URL format');
}
// Parse URL
const parsedData = myTischtennisUrlParserService.parseUrl(url);
// Try to fetch additional data if user is authenticated
const userIdOrEmail = req.headers.userid;
let completeData = parsedData;
if (userIdOrEmail) {
// Get actual user ID
let userId = userIdOrEmail;
if (isNaN(userIdOrEmail)) {
const user = await User.findOne({ where: { email: userIdOrEmail } });
if (user) userId = user.id;
}
try {
const account = await myTischtennisService.getAccount(userId);
if (account && account.accessToken) {
completeData = await myTischtennisUrlParserService.fetchTeamData(
parsedData,
account.cookie,
account.accessToken
);
}
} catch (error) {
console.error('Error fetching additional team data:', error);
// Continue with parsed data only
}
}
res.json({
success: true,
data: completeData
});
} catch (error) {
next(error);
}
}
/**
* Configure team from myTischtennis URL
* POST /api/mytischtennis/configure-team
* Body: { url: string, clubTeamId: number, createLeague?: boolean, createSeason?: boolean }
*/
async configureTeam(req, res, next) {
try {
const { url, clubTeamId, createLeague, createSeason } = req.body;
const userIdOrEmail = req.headers.userid;
if (!url || !clubTeamId) {
throw new HttpError(400, 'URL and clubTeamId are required');
}
// Get actual user ID
let userId = userIdOrEmail;
if (isNaN(userIdOrEmail)) {
const user = await User.findOne({ where: { email: userIdOrEmail } });
if (!user) {
throw new HttpError(404, 'User not found');
}
userId = user.id;
}
// Parse URL
const parsedData = myTischtennisUrlParserService.parseUrl(url);
// Try to fetch additional data
let completeData = parsedData;
const account = await myTischtennisService.getAccount(userId);
if (account && account.accessToken) {
try {
completeData = await myTischtennisUrlParserService.fetchTeamData(
parsedData,
account.cookie,
account.accessToken
);
} catch (error) {
console.error('Error fetching team data:', error);
}
}
// Find or create season
let season = await Season.findOne({
where: { season: completeData.season }
});
if (!season && createSeason) {
season = await Season.create({
season: completeData.season
});
}
if (!season) {
throw new HttpError(404, `Season ${completeData.season} not found. Set createSeason=true to create it.`);
}
// Find or create league
const team = await ClubTeam.findByPk(clubTeamId);
if (!team) {
throw new HttpError(404, 'Club team not found');
}
let league;
// First, try to find existing league by name and season
const leagueName = completeData.leagueName || completeData.groupname;
league = await League.findOne({
where: {
name: leagueName,
seasonId: season.id,
clubId: team.clubId
}
});
if (league) {
devLog(`Found existing league: ${league.name} (ID: ${league.id})`);
// Update myTischtennis fields
await league.update({
myTischtennisGroupId: completeData.groupId,
association: completeData.association,
groupname: completeData.groupname
});
} else if (team.leagueId) {
// Team has a league assigned, update it
league = await League.findByPk(team.leagueId);
if (league) {
devLog(`Updating team's existing league: ${league.name} (ID: ${league.id})`);
await league.update({
name: leagueName,
myTischtennisGroupId: completeData.groupId,
association: completeData.association,
groupname: completeData.groupname
});
}
} else if (createLeague) {
// Create new league
devLog(`Creating new league: ${leagueName}`);
league = await League.create({
name: leagueName,
seasonId: season.id,
clubId: team.clubId,
myTischtennisGroupId: completeData.groupId,
association: completeData.association,
groupname: completeData.groupname
});
} else {
throw new HttpError(400, 'League not found and team has no league assigned. Set createLeague=true to create one.');
}
// Update team
await team.update({
myTischtennisTeamId: completeData.teamId,
leagueId: league.id,
seasonId: season.id
});
res.json({
success: true,
message: 'Team configured successfully',
data: {
team: {
id: team.id,
name: team.name,
myTischtennisTeamId: completeData.teamId
},
league: {
id: league.id,
name: league.name,
myTischtennisGroupId: completeData.groupId,
association: completeData.association,
groupname: completeData.groupname
},
season: {
id: season.id,
name: season.season
},
parsedData: completeData
}
});
} catch (error) {
next(error);
}
}
/**
* Manually fetch team data from myTischtennis
* POST /api/mytischtennis/fetch-team-data
* Body: { clubTeamId: number }
*/
async fetchTeamData(req, res, next) {
try {
const { clubTeamId } = req.body;
const userIdOrEmail = req.headers.userid;
if (!clubTeamId) {
throw new HttpError(400, 'clubTeamId is required');
}
// Get actual user ID (userid header might be email address)
let userId = userIdOrEmail;
if (isNaN(userIdOrEmail)) {
// It's an email, find the user
const user = await User.findOne({ where: { email: userIdOrEmail } });
if (!user) {
throw new HttpError(404, 'User not found');
}
userId = user.id;
}
// Get myTischtennis session (similar to memberService.updateRatingsFromMyTischtennis)
console.log('Fetching session for userId:', userId, '(from header:', userIdOrEmail, ')');
let session;
try {
session = await myTischtennisService.getSession(userId);
console.log('Session found:', !!session);
} catch (sessionError) {
console.log('Session invalid, attempting login...', sessionError.message);
// Versuche automatischen Login mit gespeicherten Credentials
try {
await myTischtennisService.verifyLogin(userId);
session = await myTischtennisService.getSession(userId);
console.log('Automatic login successful');
} catch (loginError) {
console.error('Automatic login failed:', loginError.message);
throw new HttpError(401, 'MyTischtennis-Session abgelaufen und automatischer Login fehlgeschlagen. Bitte melden Sie sich in den MyTischtennis-Einstellungen an.');
}
}
// Get account data (for clubId, etc.)
const account = await myTischtennisService.getAccount(userId);
if (!account) {
throw new HttpError(404, 'MyTischtennis-Account nicht verknüpft. Bitte verknüpfen Sie Ihren Account in den MyTischtennis-Einstellungen.');
}
console.log('Using session:', {
email: account.email,
hasCookie: !!session.cookie,
hasAccessToken: !!session.accessToken,
expiresAt: new Date(session.expiresAt * 1000)
});
// Get team with league and season
const team = await ClubTeam.findByPk(clubTeamId, {
include: [
{
model: League,
as: 'league',
include: [
{
model: Season,
as: 'season'
}
]
}
]
});
if (!team) {
throw new HttpError(404, 'Team not found');
}
console.log('Team data:', {
id: team.id,
name: team.name,
myTischtennisTeamId: team.myTischtennisTeamId,
hasLeague: !!team.league,
leagueData: team.league ? {
id: team.league.id,
name: team.league.name,
myTischtennisGroupId: team.league.myTischtennisGroupId,
association: team.league.association,
groupname: team.league.groupname,
hasSeason: !!team.league.season
} : null
});
if (!team.myTischtennisTeamId || !team.league || !team.league.myTischtennisGroupId) {
throw new HttpError(400, 'Team is not configured for myTischtennis');
}
// Fetch data for this specific team
const result = await autoFetchMatchResultsService.fetchTeamResults(
{
userId: account.userId,
email: account.email,
cookie: session.cookie,
accessToken: session.accessToken,
expiresAt: session.expiresAt,
getPassword: () => null // Not needed for manual fetch
},
team
);
res.json({
success: true,
message: `${result.fetchedCount} Datensätze abgerufen und verarbeitet`,
data: {
fetchedCount: result.fetchedCount,
teamName: team.name
}
});
} catch (error) {
console.error('Error in fetchTeamData:', error);
console.error('Error stack:', error.stack);
next(error);
}
}
/**
* Get myTischtennis URL for a team
* GET /api/mytischtennis/team-url/:teamId
*/
async getTeamUrl(req, res, next) {
try {
const { teamId } = req.params;
const team = await ClubTeam.findByPk(teamId, {
include: [
{
model: League,
as: 'league',
include: [
{
model: Season,
as: 'season'
}
]
}
]
});
if (!team) {
throw new HttpError(404, 'Team not found');
}
if (!team.myTischtennisTeamId || !team.league || !team.league.myTischtennisGroupId) {
throw new HttpError(400, 'Team is not configured for myTischtennis');
}
const url = myTischtennisUrlParserService.buildUrl({
association: team.league.association,
season: team.league.season.name,
groupname: team.league.groupname,
groupId: team.league.myTischtennisGroupId,
teamId: team.myTischtennisTeamId,
teamname: team.name
});
res.json({
success: true,
url
});
} catch (error) {
next(error);
}
}
}
export default new MyTischtennisUrlController();

View File

@@ -0,0 +1,28 @@
-- Migration: Add match result fields to match table
-- Date: 2025-01-27
-- For MariaDB
-- Add myTischtennis meeting ID
ALTER TABLE `match`
ADD COLUMN my_tischtennis_meeting_id VARCHAR(255) NULL UNIQUE COMMENT 'Meeting ID from myTischtennis (e.g. 15440488)';
-- Add home match points
ALTER TABLE `match`
ADD COLUMN home_match_points INT DEFAULT 0 NULL COMMENT 'Match points won by home team';
-- Add guest match points
ALTER TABLE `match`
ADD COLUMN guest_match_points INT DEFAULT 0 NULL COMMENT 'Match points won by guest team';
-- Add is_completed flag
ALTER TABLE `match`
ADD COLUMN is_completed BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'Whether the match is completed';
-- Add PDF URL
ALTER TABLE `match`
ADD COLUMN pdf_url VARCHAR(512) NULL COMMENT 'PDF URL from myTischtennis';
-- Create indexes
CREATE INDEX idx_match_my_tischtennis_meeting_id ON `match`(my_tischtennis_meeting_id);
CREATE INDEX idx_match_is_completed ON `match`(is_completed);

View File

@@ -0,0 +1,19 @@
-- Migration: Add myTischtennis fields to league table
-- Date: 2025-01-27
-- For MariaDB
-- Add my_tischtennis_group_id column
ALTER TABLE league
ADD COLUMN my_tischtennis_group_id VARCHAR(255) NULL COMMENT 'Group ID from myTischtennis (e.g. 504417)';
-- Add association column
ALTER TABLE league
ADD COLUMN association VARCHAR(255) NULL COMMENT 'Association/Verband (e.g. HeTTV)';
-- Add groupname column
ALTER TABLE league
ADD COLUMN groupname VARCHAR(255) NULL COMMENT 'Group name for URL (e.g. 1.Kreisklasse)';
-- Create index for efficient querying
CREATE INDEX idx_league_my_tischtennis_group_id ON league(my_tischtennis_group_id);

View File

@@ -0,0 +1,11 @@
-- Migration: Add myTischtennis player ID to member table
-- Date: 2025-01-27
-- For MariaDB
-- Add my_tischtennis_player_id column
ALTER TABLE member
ADD COLUMN my_tischtennis_player_id VARCHAR(255) NULL COMMENT 'Player ID from myTischtennis (e.g. NU2705037)';
-- Create index for efficient querying
CREATE INDEX idx_member_my_tischtennis_player_id ON member(my_tischtennis_player_id);

View File

@@ -0,0 +1,11 @@
-- Migration: Add myTischtennis team ID to club_team table
-- Date: 2025-01-27
-- For MariaDB
-- Add my_tischtennis_team_id column
ALTER TABLE club_team
ADD COLUMN my_tischtennis_team_id VARCHAR(255) NULL COMMENT 'Team ID from myTischtennis (e.g. 2995094)';
-- Create index for efficient querying
CREATE INDEX idx_club_team_my_tischtennis_team_id ON club_team(my_tischtennis_team_id);

View File

@@ -0,0 +1,8 @@
-- Migration: Make locationId optional in match table
-- Date: 2025-01-27
-- For MariaDB
-- Modify locationId to allow NULL
ALTER TABLE `match`
MODIFY COLUMN location_id INT NULL;

View File

@@ -45,6 +45,12 @@ const ClubTeam = sequelize.define('ClubTeam', {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
myTischtennisTeamId: {
type: DataTypes.STRING,
allowNull: true,
comment: 'Team ID from myTischtennis (e.g. 2995094)',
field: 'my_tischtennis_team_id'
},
}, {
underscored: true,
tableName: 'club_team',

View File

@@ -34,6 +34,22 @@ const League = sequelize.define('League', {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
myTischtennisGroupId: {
type: DataTypes.STRING,
allowNull: true,
comment: 'Group ID from myTischtennis (e.g. 504417)',
field: 'my_tischtennis_group_id'
},
association: {
type: DataTypes.STRING,
allowNull: true,
comment: 'Association/Verband (e.g. HeTTV)',
},
groupname: {
type: DataTypes.STRING,
allowNull: true,
comment: 'Group name for URL (e.g. 1.Kreisklasse)',
},
}, {
underscored: true,
tableName: 'league',

View File

@@ -26,7 +26,7 @@ const Match = sequelize.define('Match', {
model: Location,
key: 'id',
},
allowNull: false,
allowNull: true,
},
homeTeamId: {
type: DataTypes.INTEGER,
@@ -75,6 +75,40 @@ const Match = sequelize.define('Match', {
allowNull: true,
comment: 'Pin-Code für Gastteam aus PDF-Parsing'
},
myTischtennisMeetingId: {
type: DataTypes.STRING,
allowNull: true,
unique: true,
comment: 'Meeting ID from myTischtennis (e.g. 15440488)',
field: 'my_tischtennis_meeting_id'
},
homeMatchPoints: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 0,
comment: 'Match points won by home team',
field: 'home_match_points'
},
guestMatchPoints: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 0,
comment: 'Match points won by guest team',
field: 'guest_match_points'
},
isCompleted: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: 'Whether the match is completed',
field: 'is_completed'
},
pdfUrl: {
type: DataTypes.STRING,
allowNull: true,
comment: 'PDF URL from myTischtennis',
field: 'pdf_url'
},
}, {
underscored: true,
tableName: 'match',

View File

@@ -137,6 +137,12 @@ const Member = sequelize.define('Member', {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: null
},
myTischtennisPlayerId: {
type: DataTypes.STRING,
allowNull: true,
comment: 'Player ID from myTischtennis (e.g. NU2705037)',
field: 'my_tischtennis_player_id'
}
}, {
underscored: true,

View File

@@ -121,6 +121,9 @@ Team.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
Club.hasMany(League, { foreignKey: 'clubId', as: 'leagues' });
League.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
Season.hasMany(League, { foreignKey: 'seasonId', as: 'leagues' });
League.belongsTo(Season, { foreignKey: 'seasonId', as: 'season' });
League.hasMany(Team, { foreignKey: 'leagueId', as: 'teams' });
Team.belongsTo(League, { foreignKey: 'leagueId', as: 'league' });

View File

@@ -1,7 +1,7 @@
{
"name": "backend",
"version": "1.0.0",
"main": "index.js",
"main": "server.js",
"type": "module",
"scripts": {
"postinstall": "cd ../frontend && npm install && npm run build",

View File

@@ -1,5 +1,6 @@
import express from 'express';
import myTischtennisController from '../controllers/myTischtennisController.js';
import myTischtennisUrlController from '../controllers/myTischtennisUrlController.js';
import { authenticate } from '../middleware/authMiddleware.js';
const router = express.Router();
@@ -28,5 +29,17 @@ router.get('/session', myTischtennisController.getSession);
// GET /api/mytischtennis/update-history - Get update ratings history
router.get('/update-history', myTischtennisController.getUpdateHistory);
// POST /api/mytischtennis/parse-url - Parse myTischtennis URL
router.post('/parse-url', myTischtennisUrlController.parseUrl);
// POST /api/mytischtennis/configure-team - Configure team from URL
router.post('/configure-team', myTischtennisUrlController.configureTeam);
// POST /api/mytischtennis/fetch-team-data - Manually fetch team data
router.post('/fetch-team-data', myTischtennisUrlController.fetchTeamData);
// GET /api/mytischtennis/team-url/:teamId - Get myTischtennis URL for team
router.get('/team-url/:teamId', myTischtennisUrlController.getTeamUrl);
export default router;

View File

@@ -195,7 +195,9 @@ app.get('*', (req, res) => {
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);
console.log('Scheduler service started - Rating updates scheduled for 6:00 AM daily');
console.log('Scheduler service started:');
console.log(' - Rating updates: 6:00 AM daily');
console.log(' - Match results fetch: 6:30 AM daily');
});
} catch (err) {
console.error('Unable to synchronize the database:', err);

1778
backend/server.log Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,657 @@
import myTischtennisService from './myTischtennisService.js';
import myTischtennisClient from '../clients/myTischtennisClient.js';
import MyTischtennis from '../models/MyTischtennis.js';
import ClubTeam from '../models/ClubTeam.js';
import League from '../models/League.js';
import Season from '../models/Season.js';
import Member from '../models/Member.js';
import Match from '../models/Match.js';
import Team from '../models/Team.js';
import { Op } from 'sequelize';
import { devLog } from '../utils/logger.js';
class AutoFetchMatchResultsService {
/**
* Execute automatic match results fetching for all users with enabled auto-updates
*/
async executeAutomaticFetch() {
devLog('Starting automatic match results fetch...');
try {
// Find all users with auto-updates enabled
const accounts = await MyTischtennis.findAll({
where: {
autoUpdateRatings: true, // Nutze das gleiche Flag
savePassword: true // Must have saved password
},
attributes: ['id', 'userId', 'email', 'encryptedPassword', 'accessToken', 'expiresAt', 'cookie']
});
devLog(`Found ${accounts.length} accounts with auto-updates enabled for match results`);
if (accounts.length === 0) {
devLog('No accounts found with auto-updates enabled');
return;
}
// Process each account
for (const account of accounts) {
await this.processAccount(account);
}
devLog('Automatic match results fetch completed');
} catch (error) {
console.error('Error in automatic match results fetch:', error);
}
}
/**
* Process a single account for match results fetching
*/
async processAccount(account) {
const startTime = Date.now();
let success = false;
let message = '';
let errorDetails = null;
let fetchedCount = 0;
try {
devLog(`Processing match results for account ${account.email} (User ID: ${account.userId})`);
// Check if session is still valid
if (!account.accessToken || !account.expiresAt || account.expiresAt < Date.now() / 1000) {
devLog(`Session expired for ${account.email}, attempting re-login`);
// Try to re-login with stored password
const password = account.getPassword();
if (!password) {
throw new Error('No stored password available for re-login');
}
const loginResult = await myTischtennisClient.login(account.email, password);
if (!loginResult.success) {
throw new Error(`Re-login failed: ${loginResult.error}`);
}
// Update session data
account.accessToken = loginResult.accessToken;
account.refreshToken = loginResult.refreshToken;
account.expiresAt = loginResult.expiresAt;
account.cookie = loginResult.cookie;
await account.save();
devLog(`Successfully re-logged in for ${account.email}`);
}
// Perform match results fetch
const fetchResult = await this.fetchMatchResults(account);
fetchedCount = fetchResult.fetchedCount || 0;
success = true;
message = `Successfully fetched ${fetchedCount} match results`;
devLog(`Fetched ${fetchedCount} match results for ${account.email}`);
} catch (error) {
success = false;
message = 'Match results fetch failed';
errorDetails = error.message;
console.error(`Error fetching match results for ${account.email}:`, error);
}
const executionTime = Date.now() - startTime;
// TODO: Log the attempt to a dedicated match results history table
devLog(`Match results fetch for ${account.email}: ${success ? 'SUCCESS' : 'FAILED'} (${executionTime}ms)`);
}
/**
* Fetch match results for a specific account
*/
async fetchMatchResults(account) {
devLog(`Fetching match results for ${account.email}`);
let totalFetched = 0;
try {
// Get all teams for this user's clubs that have myTischtennis IDs configured
const teams = await ClubTeam.findAll({
where: {
myTischtennisTeamId: {
[Op.ne]: null
}
},
include: [
{
model: League,
as: 'league',
where: {
myTischtennisGroupId: {
[Op.ne]: null
},
association: {
[Op.ne]: null
}
},
include: [
{
model: Season,
as: 'season'
}
]
}
]
});
devLog(`Found ${teams.length} teams with myTischtennis configuration`);
// Fetch results for each team
for (const team of teams) {
try {
const result = await this.fetchTeamResults(account, team);
totalFetched += result.fetchedCount;
} catch (error) {
console.error(`Error fetching results for team ${team.name}:`, error);
}
}
return {
success: true,
fetchedCount: totalFetched
};
} catch (error) {
console.error('Error in fetchMatchResults:', error);
throw error;
}
}
/**
* Fetch results for a specific team
*/
async fetchTeamResults(account, team) {
const league = team.league;
const season = league.season;
// Build the myTischtennis URL
// Convert full season (e.g. "2025/2026") to short format (e.g. "25/26") for API
const seasonFull = season.season; // e.g. "2025/2026"
const seasonParts = seasonFull.split('/');
const seasonShort = seasonParts.length === 2
? `${seasonParts[0].slice(-2)}/${seasonParts[1].slice(-2)}`
: seasonFull;
const seasonStr = seasonShort.replace('/', '--'); // e.g. "25/26" -> "25--26"
const teamnameEncoded = encodeURIComponent(team.name.replace(/\s/g, '_'));
devLog(`=== FETCH TEAM RESULTS ===`);
devLog(`Team name (from ClubTeam): ${team.name}`);
devLog(`Team name encoded: ${teamnameEncoded}`);
devLog(`MyTischtennis Team ID: ${team.myTischtennisTeamId}`);
let totalProcessed = 0;
try {
// 1. Fetch player statistics (Spielerbilanzen)
const playerStatsUrl = `https://www.mytischtennis.de/click-tt/${league.association}/${seasonStr}/ligen/${league.groupname}/gruppe/${league.myTischtennisGroupId}/mannschaft/${team.myTischtennisTeamId}/${teamnameEncoded}/spielerbilanzen/gesamt?_data=routes%2Fclick-tt%2B%2F%24association%2B%2F%24season%2B%2F%24type%2B%2F%28%24groupname%29.gruppe.%24urlid_.mannschaft.%24teamid.%24teamname%2B%2Fspielerbilanzen.%24filter`;
devLog(`Fetching player stats from: ${playerStatsUrl}`);
const playerStatsResponse = await fetch(playerStatsUrl, {
headers: {
'Cookie': account.cookie || '',
'Authorization': `Bearer ${account.accessToken}`,
'Accept': 'application/json'
}
});
if (playerStatsResponse.ok) {
const playerStatsData = await playerStatsResponse.json();
// Log complete response for debugging
console.log('=== PLAYER STATS RESPONSE START ===');
console.log(JSON.stringify(playerStatsData, null, 2));
console.log('=== PLAYER STATS RESPONSE END ===');
const playerCount = await this.processTeamData(team, playerStatsData);
totalProcessed += playerCount;
devLog(`Processed ${playerCount} player statistics`);
}
// Note: Match results are already included in the player stats response above
// in tableData.meetings_excerpt.meetings, so we don't need a separate call
return {
success: true,
fetchedCount: totalProcessed
};
} catch (error) {
console.error(`Error fetching team results for ${team.name}:`, error);
throw error;
}
}
/**
* Process and store team data from myTischtennis
*/
async processTeamData(team, data) {
// TODO: Implement data processing and storage
// This would typically involve:
// 1. Extract player statistics from data.data.balancesheet
// 2. Match players with local Member records (by player_id or name)
// 3. Update or create match statistics
// 4. Store historical data for tracking changes
devLog(`Processing data for team ${team.name}`);
if (!data.data || !data.data.balancesheet || !Array.isArray(data.data.balancesheet)) {
devLog('No balancesheet data found');
return 0;
}
let processedCount = 0;
for (const teamData of data.data.balancesheet) {
// Process single player statistics
if (teamData.single_player_statistics) {
for (const playerStat of teamData.single_player_statistics) {
devLog(`Player: ${playerStat.player_firstname} ${playerStat.player_lastname} (ID: ${playerStat.player_id})`);
devLog(` Points won: ${playerStat.points_won}, Points lost: ${playerStat.points_lost}`);
// Try to match player with local Member
const member = await this.matchPlayer(
playerStat.player_id,
playerStat.player_firstname,
playerStat.player_lastname
);
if (member) {
devLog(` Matched with local member: ${member.firstName} ${member.lastName} (ID: ${member.id})`);
// Update player statistics (TTR/QTTR would be fetched from different endpoint)
// For now, we just ensure the myTischtennis ID is stored
if (!member.myTischtennisPlayerId) {
member.myTischtennisPlayerId = playerStat.player_id;
await member.save();
devLog(` Updated myTischtennis Player ID for ${member.firstName} ${member.lastName}`);
}
} else {
devLog(` No local member found for ${playerStat.player_firstname} ${playerStat.player_lastname}`);
}
processedCount++;
}
}
// Process double player statistics
if (teamData.double_player_statistics) {
for (const doubleStat of teamData.double_player_statistics) {
devLog(`Double: ${doubleStat.firstname_player_1} ${doubleStat.lastname_player_1} / ${doubleStat.firstname_player_2} ${doubleStat.lastname_player_2}`);
devLog(` Points won: ${doubleStat.points_won}, Points lost: ${doubleStat.points_lost}`);
// TODO: Store double statistics
processedCount++;
}
}
}
// Also process meetings from the player stats response
if (data.data && data.data.balancesheet && data.data.balancesheet[0]) {
const teamData = data.data.balancesheet[0];
// Check for meetings_excerpt in the tableData section
if (data.tableData && data.tableData.meetings_excerpt && data.tableData.meetings_excerpt.meetings) {
devLog('Found meetings_excerpt in tableData, processing matches...');
const meetingsProcessed = await this.processMatchResults(team, { data: data.tableData });
devLog(`Processed ${meetingsProcessed} matches from player stats response`);
}
}
return processedCount;
}
/**
* Process match results from schedule/table data
*/
async processMatchResults(team, data) {
devLog(`Processing match results for team ${team.name}`);
// Handle different response structures from different endpoints
const meetingsExcerpt = data.data?.meetings_excerpt || data.tableData?.meetings_excerpt;
if (!meetingsExcerpt) {
devLog('No meetings_excerpt data found in response');
return 0;
}
let processedCount = 0;
// Handle both response structures:
// 1. With meetings property: meetings_excerpt.meetings (array of date objects)
// 2. Direct array: meetings_excerpt (array of date objects)
const meetings = meetingsExcerpt.meetings || meetingsExcerpt;
if (!Array.isArray(meetings) || meetings.length === 0) {
devLog('No meetings array found or empty');
return 0;
}
devLog(`Found ${meetings.length} items in meetings array`);
// Check if meetings is an array of date objects or an array of match objects
const firstItem = meetings[0];
const isDateGrouped = firstItem && typeof firstItem === 'object' && !firstItem.meeting_id;
if (isDateGrouped) {
// Format 1: Array of date objects (Spielerbilanzen, Spielplan)
devLog('Processing date-grouped meetings...');
for (const dateGroup of meetings) {
for (const [date, matchList] of Object.entries(dateGroup)) {
for (const match of matchList) {
devLog(`Match: ${match.team_home} vs ${match.team_away}`);
devLog(` Date: ${match.date}`);
devLog(` Status: ${match.state} (${match.is_meeting_complete ? 'complete' : 'incomplete'})`);
devLog(` Result: ${match.matches_won}:${match.matches_lost}`);
devLog(` Meeting ID: ${match.meeting_id}`);
try {
await this.storeMatchResult(team, match, false);
processedCount++;
} catch (error) {
console.error(`Error storing match result for meeting ${match.meeting_id}:`, error);
}
}
}
}
} else {
// Format 2: Flat array of match objects (Tabelle)
devLog('Processing flat meetings array...');
for (const match of meetings) {
devLog(`Match: ${match.team_home} vs ${match.team_away}`);
devLog(` Date: ${match.date}`);
devLog(` Status: ${match.state} (${match.is_meeting_complete ? 'complete' : 'incomplete'})`);
devLog(` Result: ${match.matches_won}:${match.matches_lost}`);
devLog(` Meeting ID: ${match.meeting_id}`);
try {
await this.storeMatchResult(team, match, false);
processedCount++;
} catch (error) {
console.error(`Error storing match result for meeting ${match.meeting_id}:`, error);
}
}
}
devLog(`Processed ${processedCount} matches in league ${team.leagueId}`);
return processedCount;
}
/**
* Store or update match result in database
*/
async storeMatchResult(ourClubTeam, matchData, isHomeTeam) {
// Parse match points from myTischtennis data
// matchData.matches_won/lost are ALWAYS from the perspective of team_home in myTischtennis
// So we need to assign them correctly based on whether WE are home or guest
const mtHomePoints = parseInt(matchData.matches_won) || 0;
const mtGuestPoints = parseInt(matchData.matches_lost) || 0;
// If matchData has team_home and team_away, we can determine our role
// But isHomeTeam parameter tells us if WE (ourClubTeam) are playing at home
const homeMatchPoints = mtHomePoints;
const guestMatchPoints = mtGuestPoints;
devLog(`Match points from myTischtennis: ${mtHomePoints}:${mtGuestPoints} (from team_home perspective)`);
// Find existing match by meeting ID OR by date and team names
devLog(`Searching for existing match with meeting ID: ${matchData.meeting_id}`);
let match = await Match.findOne({
where: { myTischtennisMeetingId: matchData.meeting_id }
});
if (match) {
devLog(`Found match by meeting ID: ${match.id}`);
}
// If not found by meeting ID, try to find by date and teams
if (!match) {
devLog(`No match found by meeting ID, searching by date and teams...`);
const matchDate = new Date(matchData.date);
const startOfDay = new Date(matchDate.setHours(0, 0, 0, 0));
const endOfDay = new Date(matchDate.setHours(23, 59, 59, 999));
devLog(`Searching matches on ${matchData.date} in league ${ourClubTeam.leagueId}`);
const potentialMatches = await Match.findAll({
where: {
date: {
[Op.between]: [startOfDay, endOfDay]
},
leagueId: ourClubTeam.leagueId
},
include: [
{ model: Team, as: 'homeTeam' },
{ model: Team, as: 'guestTeam' }
]
});
devLog(`Found ${potentialMatches.length} potential matches on this date`);
// Find by team names
for (const m of potentialMatches) {
devLog(` Checking match ${m.id}: ${m.homeTeam?.name} vs ${m.guestTeam?.name}`);
devLog(` Against: ${matchData.team_home} vs ${matchData.team_away}`);
const homeNameMatch = m.homeTeam?.name === matchData.team_home ||
m.homeTeam?.name.includes(matchData.team_home) ||
matchData.team_home.includes(m.homeTeam?.name);
const guestNameMatch = m.guestTeam?.name === matchData.team_away ||
m.guestTeam?.name.includes(matchData.team_away) ||
matchData.team_away.includes(m.guestTeam?.name);
devLog(` Home match: ${homeNameMatch}, Guest match: ${guestNameMatch}`);
if (homeNameMatch && guestNameMatch) {
match = m;
devLog(` ✓ Found existing match by date and teams: ${match.id}`);
break;
}
}
if (!match) {
devLog(`No existing match found, will create new one`);
}
}
if (match) {
// Update existing match
// IMPORTANT: Check if the teams are in the same order as in myTischtennis
// Load the match with team associations to compare
const matchWithTeams = await Match.findByPk(match.id, {
include: [
{ model: Team, as: 'homeTeam' },
{ model: Team, as: 'guestTeam' }
]
});
// Compare team names to determine if we need to swap points
const dbHomeTeamName = matchWithTeams.homeTeam?.name || '';
const dbGuestTeamName = matchWithTeams.guestTeam?.name || '';
const mtHomeTeamName = matchData.team_home;
const mtGuestTeamName = matchData.team_away;
// Check if teams are in the same order
const teamsMatch = (
dbHomeTeamName === mtHomeTeamName ||
dbHomeTeamName.includes(mtHomeTeamName) ||
mtHomeTeamName.includes(dbHomeTeamName)
);
let finalHomePoints, finalGuestPoints;
if (teamsMatch) {
// Teams are in same order
finalHomePoints = homeMatchPoints;
finalGuestPoints = guestMatchPoints;
devLog(`Teams in same order: ${dbHomeTeamName} = ${mtHomeTeamName}`);
} else {
// Teams are swapped - need to swap points!
finalHomePoints = guestMatchPoints;
finalGuestPoints = homeMatchPoints;
devLog(`Teams are SWAPPED! DB: ${dbHomeTeamName} vs ${dbGuestTeamName}, MyTT: ${mtHomeTeamName} vs ${mtGuestTeamName}`);
devLog(`Swapping points: ${homeMatchPoints}:${guestMatchPoints}${finalHomePoints}:${finalGuestPoints}`);
}
const updateData = {
homeMatchPoints: finalHomePoints,
guestMatchPoints: finalGuestPoints,
isCompleted: matchData.is_meeting_complete,
pdfUrl: matchData.pdf_url,
myTischtennisMeetingId: matchData.meeting_id // Store meeting ID for future updates
};
await match.update(updateData);
devLog(`Updated existing match ${match.id} (Meeting ${matchData.meeting_id}): ${finalHomePoints}:${finalGuestPoints} (${matchData.is_meeting_complete ? 'complete' : 'incomplete'})`);
} else {
// Create new match
devLog(`Creating new match for meeting ${matchData.meeting_id}`);
try {
// Find or create home and guest teams based on myTischtennis team IDs
const homeTeam = await this.findOrCreateTeam(
matchData.team_home,
matchData.team_home_id,
ourClubTeam
);
const guestTeam = await this.findOrCreateTeam(
matchData.team_away,
matchData.team_away_id,
ourClubTeam
);
// Extract time from date
const matchDate = new Date(matchData.date);
const time = `${String(matchDate.getHours()).padStart(2, '0')}:${String(matchDate.getMinutes()).padStart(2, '0')}:00`;
// Create match (points are already correctly set from matchData)
match = await Match.create({
date: matchData.date,
time: time,
locationId: null, // Location is not provided by myTischtennis
homeTeamId: homeTeam.id,
guestTeamId: guestTeam.id,
leagueId: ourClubTeam.leagueId,
clubId: ourClubTeam.clubId,
myTischtennisMeetingId: matchData.meeting_id,
homeMatchPoints: homeMatchPoints,
guestMatchPoints: guestMatchPoints,
isCompleted: matchData.is_meeting_complete,
pdfUrl: matchData.pdf_url
});
devLog(`Created new match ${match.id}: ${matchData.team_home} vs ${matchData.team_away} (${homeMatchPoints}:${guestMatchPoints}, ${matchData.is_meeting_complete ? 'complete' : 'incomplete'})`);
} catch (error) {
console.error(`Error creating match for meeting ${matchData.meeting_id}:`, error);
devLog(` Home: ${matchData.team_home} (myTT ID: ${matchData.team_home_id})`);
devLog(` Guest: ${matchData.team_away} (myTT ID: ${matchData.team_away_id})`);
}
}
return match;
}
/**
* Find or create a Team in the team table
* All teams (own and opponents) are stored in the team table
*/
async findOrCreateTeam(teamName, myTischtennisTeamId, ourClubTeam) {
devLog(`Finding team: ${teamName} (myTT ID: ${myTischtennisTeamId})`);
// Search in team table for all teams in this league
const allTeamsInLeague = await Team.findAll({
where: {
leagueId: ourClubTeam.leagueId,
seasonId: ourClubTeam.seasonId
}
});
devLog(` Searching in ${allTeamsInLeague.length} teams in league ${ourClubTeam.leagueId}`);
// Try exact match first
let team = allTeamsInLeague.find(t => t.name === teamName);
if (team) {
devLog(` ✓ Found team by exact name: ${team.name} (ID: ${team.id})`);
return team;
}
// If not found, try fuzzy match
team = allTeamsInLeague.find(t =>
t.name.includes(teamName) ||
teamName.includes(t.name)
);
if (team) {
devLog(` ✓ Found team by fuzzy match: ${team.name} (ID: ${team.id})`);
return team;
}
// Team not found - create it
team = await Team.create({
name: teamName,
clubId: ourClubTeam.clubId,
leagueId: ourClubTeam.leagueId,
seasonId: ourClubTeam.seasonId
});
devLog(` ✓ Created new team: ${team.name} (ID: ${team.id})`);
return team;
}
/**
* Match a myTischtennis player with a local Member
*/
async matchPlayer(playerId, firstName, lastName) {
// First, try to find by myTischtennis Player ID
if (playerId) {
const member = await Member.findOne({
where: { myTischtennisPlayerId: playerId }
});
if (member) {
return member;
}
}
// If not found, try to match by name (fuzzy matching)
// Note: Since names are encrypted, we need to get all members and decrypt
// This is not efficient for large databases, but works for now
const allMembers = await Member.findAll();
for (const member of allMembers) {
const memberFirstName = member.firstName?.toLowerCase().trim();
const memberLastName = member.lastName?.toLowerCase().trim();
const searchFirstName = firstName?.toLowerCase().trim();
const searchLastName = lastName?.toLowerCase().trim();
if (memberFirstName === searchFirstName && memberLastName === searchLastName) {
return member;
}
}
return null;
}
/**
* Get all accounts with auto-fetch enabled (for manual execution)
*/
async getAutoFetchAccounts() {
return await MyTischtennis.findAll({
where: {
autoUpdateRatings: true
},
attributes: ['userId', 'email', 'autoUpdateRatings']
});
}
}
export default new AutoFetchMatchResultsService();

View File

@@ -35,6 +35,7 @@ class ClubTeamService {
clubId: clubTeam.clubId,
leagueId: clubTeam.leagueId,
seasonId: clubTeam.seasonId,
myTischtennisTeamId: clubTeam.myTischtennisTeamId,
createdAt: clubTeam.createdAt,
updatedAt: clubTeam.updatedAt,
league: { name: 'Unbekannt' },
@@ -43,7 +44,9 @@ class ClubTeamService {
// Lade Liga-Daten
if (clubTeam.leagueId) {
const league = await League.findByPk(clubTeam.leagueId, { attributes: ['name'] });
const league = await League.findByPk(clubTeam.leagueId, {
attributes: ['id', 'name', 'myTischtennisGroupId', 'association', 'groupname']
});
if (league) enrichedTeam.league = league;
}

View File

@@ -7,6 +7,8 @@ import Season from '../models/Season.js';
import Location from '../models/Location.js';
import League from '../models/League.js';
import Team from '../models/Team.js';
import ClubTeam from '../models/ClubTeam.js';
import Club from '../models/Club.js';
import SeasonService from './seasonService.js';
import { checkAccess } from '../utils/userUtils.js';
import { Op } from 'sequelize';
@@ -14,6 +16,46 @@ import { Op } from 'sequelize';
import { devLog } from '../utils/logger.js';
class MatchService {
/**
* Format team name with age class suffix
* @param {string} teamName - Base team name (e.g. "Harheimer TC")
* @param {string} ageClass - Age class (e.g. "Jugend 11", "Senioren", "Frauen", "Erwachsene")
* @returns {string} Formatted team name (e.g. "Harheimer TC (J11)")
*/
formatTeamNameWithAgeClass(teamName, ageClass) {
if (!ageClass || ageClass.trim() === '' || ageClass === 'Erwachsene') {
return teamName;
}
// Parse age class
const ageClassLower = ageClass.toLowerCase().trim();
// Senioren = S
if (ageClassLower.includes('senioren')) {
return `${teamName} (S)`;
}
// Frauen = F
if (ageClassLower.includes('frauen')) {
return `${teamName} (F)`;
}
// Jugend XX = JXX
const jugendMatch = ageClass.match(/jugend\s+(\d+)/i);
if (jugendMatch) {
return `${teamName} (J${jugendMatch[1]})`;
}
// Mädchen XX = MXX
const maedchenMatch = ageClass.match(/m[aä]dchen\s+(\d+)/i);
if (maedchenMatch) {
return `${teamName} (M${maedchenMatch[1]})`;
}
// Default: return as is
return teamName;
}
generateSeasonString(date = new Date()) {
const currentYear = date.getFullYear();
let seasonStartYear;
@@ -47,8 +89,20 @@ class MatchService {
seasonId: season.id,
},
});
const homeTeamId = await this.getOrCreateTeamId(row['HeimMannschaft'], clubId);
const guestTeamId = await this.getOrCreateTeamId(row['GastMannschaft'], clubId);
const homeTeamId = await this.getOrCreateTeamId(
row['HeimMannschaft'],
row['HeimMannschaftAltersklasse'],
clubId,
league.id,
season.id
);
const guestTeamId = await this.getOrCreateTeamId(
row['GastMannschaft'],
row['GastMannschaftAltersklasse'],
clubId,
league.id,
season.id
);
const [location] = await Location.findOrCreate({
where: {
name: row['HalleName'],
@@ -90,15 +144,24 @@ class MatchService {
}
}
async getOrCreateTeamId(teamName, clubId) {
async getOrCreateTeamId(teamName, ageClass, clubId, leagueId, seasonId) {
// Format team name with age class
const formattedTeamName = this.formatTeamNameWithAgeClass(teamName, ageClass);
devLog(`Team: "${teamName}" + "${ageClass}" -> "${formattedTeamName}"`);
const [team] = await Team.findOrCreate({
where: {
name: teamName,
clubId: clubId
name: formattedTeamName,
clubId: clubId,
leagueId: leagueId,
seasonId: seasonId
},
defaults: {
name: teamName,
clubId: clubId
name: formattedTeamName,
clubId: clubId,
leagueId: leagueId,
seasonId: seasonId
}
});
return team.id;
@@ -174,6 +237,10 @@ class MatchService {
code: match.code,
homePin: match.homePin,
guestPin: match.guestPin,
homeMatchPoints: match.homeMatchPoints || 0,
guestMatchPoints: match.guestMatchPoints || 0,
isCompleted: match.isCompleted || false,
pdfUrl: match.pdfUrl,
homeTeam: { name: 'Unbekannt' },
guestTeam: { name: 'Unbekannt' },
location: { name: 'Unbekannt', address: '', city: '', zip: '' },
@@ -213,13 +280,61 @@ class MatchService {
if (!season) {
throw new Error('Season not found');
}
const matches = await Match.findAll({
// Get club name from database
const club = await Club.findByPk(clubId, { attributes: ['name'] });
if (!club) {
throw new Error('Club not found');
}
const clubName = club.name;
devLog(`Filtering matches for club: ${clubName}`);
// Find all club teams in this league
const clubTeams = await ClubTeam.findAll({
where: {
clubId: clubId,
leagueId: leagueId
}
},
attributes: ['id', 'name']
});
devLog(`Club teams in league ${leagueId}: ${clubTeams.map(ct => ct.name).join(', ')}`);
// Find all Team entries that contain our club name
const ownTeams = await Team.findAll({
where: {
name: {
[Op.like]: `${clubName}%`
},
leagueId: leagueId
},
attributes: ['id', 'name']
});
const ownTeamIds = ownTeams.map(t => t.id);
devLog(`Own team IDs in this league: ${ownTeamIds.join(', ')} (${ownTeams.map(t => t.name).join(', ')})`);
// Load matches
let matches;
if (ownTeamIds.length > 0) {
// Load only matches where one of our teams is involved
matches = await Match.findAll({
where: {
leagueId: leagueId,
[Op.or]: [
{ homeTeamId: { [Op.in]: ownTeamIds } },
{ guestTeamId: { [Op.in]: ownTeamIds } }
]
}
});
devLog(`Found ${matches.length} matches for our teams`);
} else {
// No own teams found - show nothing
devLog('No own teams found in this league, showing no matches');
matches = [];
}
// Lade Team- und Location-Daten manuell
const enrichedMatches = [];
for (const match of matches) {
@@ -234,6 +349,10 @@ class MatchService {
code: match.code,
homePin: match.homePin,
guestPin: match.guestPin,
homeMatchPoints: match.homeMatchPoints || 0,
guestMatchPoints: match.guestMatchPoints || 0,
isCompleted: match.isCompleted || false,
pdfUrl: match.pdfUrl,
homeTeam: { name: 'Unbekannt' },
guestTeam: { name: 'Unbekannt' },
location: { name: 'Unbekannt', address: '', city: '', zip: '' },

View File

@@ -159,7 +159,7 @@ class MemberService {
// Versuche automatischen Login mit gespeicherten Credentials
try {
const loginResult = await myTischtennisService.verifyLogin(user.id);
await myTischtennisService.verifyLogin(user.id);
const freshSession = await myTischtennisService.getSession(user.id);
session = {
cookie: freshSession.cookie,

View File

@@ -0,0 +1,245 @@
import { devLog } from '../utils/logger.js';
class MyTischtennisUrlParserService {
/**
* Parse myTischtennis URL and extract configuration data
*
* Example URL:
* https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/spielerbilanzen/gesamt
*
* @param {string} url - The myTischtennis URL
* @returns {Object} Parsed configuration data
*/
parseUrl(url) {
try {
// Remove trailing slash if present
url = url.trim().replace(/\/$/, '');
// Extract parts using regex
// Pattern: /click-tt/{association}/{season}/{type}/{groupname}/gruppe/{groupId}/mannschaft/{teamId}/{teamname}/...
const pattern = /\/click-tt\/([^\/]+)\/([^\/]+)\/([^\/]+)\/([^\/]+)\/gruppe\/([^\/]+)\/mannschaft\/([^\/]+)\/([^\/]+)/;
const match = url.match(pattern);
if (!match) {
throw new Error('URL format not recognized. Expected format: /click-tt/{association}/{season}/{type}/{groupname}/gruppe/{groupId}/mannschaft/{teamId}/{teamname}/...');
}
const [
,
association,
seasonRaw,
type,
groupnameEncoded,
groupId,
teamId,
teamnameEncoded
] = match;
// Decode and process values
const seasonShort = seasonRaw.replace('--', '/'); // "25--26" -> "25/26"
const season = this.convertToFullSeason(seasonShort); // "25/26" -> "2025/2026"
const groupname = decodeURIComponent(groupnameEncoded);
const teamname = decodeURIComponent(teamnameEncoded).replace(/_/g, ' '); // "Harheimer_TC_(J11)" -> "Harheimer TC (J11)"
const result = {
association,
season,
seasonShort, // Für API-Calls
type,
groupname,
groupId,
teamId,
teamname,
originalUrl: url
};
devLog('Parsed myTischtennis URL:', result);
return result;
} catch (error) {
console.error('Error parsing myTischtennis URL:', error);
throw error;
}
}
/**
* Convert short season format to full format
* "25/26" -> "2025/2026"
* "24/25" -> "2024/2025"
*/
convertToFullSeason(seasonShort) {
const parts = seasonShort.split('/');
if (parts.length !== 2) {
return seasonShort;
}
const year1 = parseInt(parts[0]);
const year2 = parseInt(parts[1]);
// Determine century based on year1
// If year1 < 50, assume 20xx, otherwise 19xx
const century1 = year1 < 50 ? 2000 : 1900;
const century2 = year2 < 50 ? 2000 : 1900;
const fullYear1 = century1 + year1;
const fullYear2 = century2 + year2;
return `${fullYear1}/${fullYear2}`;
}
/**
* Convert full season format to short format
* "2025/2026" -> "25/26"
* "2024/2025" -> "24/25"
*/
convertToShortSeason(seasonFull) {
const parts = seasonFull.split('/');
if (parts.length !== 2) {
return seasonFull;
}
const year1 = parseInt(parts[0]);
const year2 = parseInt(parts[1]);
const shortYear1 = String(year1).slice(-2);
const shortYear2 = String(year2).slice(-2);
return `${shortYear1}/${shortYear2}`;
}
/**
* Fetch additional team data from myTischtennis
*
* @param {Object} parsedUrl - Parsed URL data from parseUrl()
* @param {string} cookie - Authentication cookie
* @param {string} accessToken - Access token
* @returns {Object} Additional team data
*/
async fetchTeamData(parsedUrl, cookie, accessToken) {
try {
const { association, seasonShort, type, groupname, groupId, teamId, teamname } = parsedUrl;
const seasonStr = seasonShort.replace('/', '--');
const teamnameEncoded = encodeURIComponent(teamname.replace(/\s/g, '_'));
// Build the API URL
const apiUrl = `https://www.mytischtennis.de/click-tt/${association}/${seasonStr}/${type}/${encodeURIComponent(groupname)}/gruppe/${groupId}/mannschaft/${teamId}/${teamnameEncoded}/spielerbilanzen/gesamt?_data=routes%2Fclick-tt%2B%2F%24association%2B%2F%24season%2B%2F%24type%2B%2F%28%24groupname%29.gruppe.%24urlid_.mannschaft.%24teamid.%24teamname%2B%2Fspielerbilanzen.%24filter`;
devLog(`Fetching team data from: ${apiUrl}`);
const response = await fetch(apiUrl, {
headers: {
'Cookie': cookie || '',
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/json',
'User-Agent': 'Mozilla/5.0'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Extract additional information
const teamData = {
clubId: null,
clubName: null,
teamName: null,
leagueName: null,
leagueShortName: null,
region: null,
tableRank: null,
matchesWon: null,
matchesLost: null
};
if (data.data && data.data.head_infos) {
const headInfos = data.data.head_infos;
teamData.clubId = data.data.balancesheet?.[0]?.club_id || null;
teamData.clubName = headInfos.club_name;
teamData.teamName = headInfos.team_name;
teamData.leagueName = headInfos.league_name;
teamData.region = headInfos.region;
teamData.tableRank = headInfos.team_table_rank;
teamData.matchesWon = headInfos.team_matches_won;
teamData.matchesLost = headInfos.team_matches_lost;
}
devLog('Fetched team data:', teamData);
return {
...parsedUrl,
...teamData,
fullData: data
};
} catch (error) {
console.error('Error fetching team data:', error);
throw error;
}
}
/**
* Complete configuration from URL
* Combines URL parsing and data fetching
*
* @param {string} url - The myTischtennis URL
* @param {string} cookie - Authentication cookie (optional)
* @param {string} accessToken - Access token (optional)
* @returns {Object} Complete configuration data
*/
async getCompleteConfig(url, cookie = null, accessToken = null) {
const parsedUrl = this.parseUrl(url);
if (cookie && accessToken) {
return await this.fetchTeamData(parsedUrl, cookie, accessToken);
}
return parsedUrl;
}
/**
* Validate if URL is a valid myTischtennis team URL
*
* @param {string} url - The URL to validate
* @returns {boolean} True if valid
*/
isValidTeamUrl(url) {
try {
this.parseUrl(url);
return true;
} catch {
return false;
}
}
/**
* Build myTischtennis URL from components
*
* @param {Object} config - Configuration object
* @returns {string} The constructed URL
*/
buildUrl(config) {
const {
association,
season,
type = 'ligen',
groupname,
groupId,
teamId,
teamname
} = config;
// Convert full season to short format for URL
const seasonShort = this.convertToShortSeason(season);
const seasonStr = seasonShort.replace('/', '--');
const teamnameEncoded = encodeURIComponent(teamname.replace(/\s/g, '_'));
const groupnameEncoded = encodeURIComponent(groupname);
return `https://www.mytischtennis.de/click-tt/${association}/${seasonStr}/${type}/${groupnameEncoded}/gruppe/${groupId}/mannschaft/${teamId}/${teamnameEncoded}/spielerbilanzen/gesamt`;
}
}
export default new MyTischtennisUrlParserService();

View File

@@ -88,11 +88,21 @@ class PDFParserService {
const result = strategy.fn(lines, clubId);
if (result.matches.length > 0) {
console.log(`[PDF Parser] Using strategy: ${strategy.name}, found ${result.matches.length} matches`);
if (result.matches.length > 0) {
console.log(`[PDF Parser] First match sample:`, {
homeTeamName: result.matches[0].homeTeamName,
guestTeamName: result.matches[0].guestTeamName,
date: result.matches[0].date,
rawLine: result.matches[0].rawLine
});
}
matches.push(...result.matches);
metadata.parsedMatches += result.matches.length;
break; // Erste erfolgreiche Strategie verwenden
}
} catch (strategyError) {
console.log(`[PDF Parser] Strategy ${strategy.name} failed:`, strategyError.message);
errors.push(`Strategy ${strategy.name} failed: ${strategyError.message}`);
}
}
@@ -148,16 +158,21 @@ class PDFParserService {
const [, day, month, year] = dateMatch;
const date = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`);
// Suche nach Zeit-Pattern direkt nach dem Datum (hh:mm) - Format: Wt.dd.mm.yyyyhh:MM
const timeMatch = line.match(/(\d{1,2})[./](\d{1,2})[./](\d{4})(\d{1,2}):(\d{2})/);
// Suche nach Zeit-Pattern (hh:mm) - kann direkt nach Datum oder mit Leerzeichen sein
const timeMatch = line.match(/(\d{1,2}):(\d{2})/);
let time = null;
if (timeMatch) {
time = `${timeMatch[4].padStart(2, '0')}:${timeMatch[5]}`;
time = `${timeMatch[1].padStart(2, '0')}:${timeMatch[2]}`;
}
// Entferne Datum und Zeit vom Anfang der Zeile
const cleanLine = line.replace(/^[A-Za-z]{2}\.(\d{1,2})[./](\d{1,2})[./](\d{4})(\d{1,2}):(\d{2})\s*/, '');
// Entferne Datum (mit optionalem Wochentag) und Zeit vom Anfang der Zeile
// Format: "Sa. 06.09.2025 10:00" oder "06.09.2025 10:00"
const cleanLine = line
.replace(/^[A-Za-z]{2,3}\.\s*/, '') // Entferne Wochentag (z.B. "Sa. ", "Mo. ", "Fre. ")
.replace(/^\d{1,2}[./]\d{1,2}[./]\d{4}/, '') // Entferne Datum
.replace(/^\s*\d{1,2}:\d{2}/, '') // Entferne Zeit
.trim();
// Entferne Nummerierung am Anfang (z.B. "(1)")
const cleanLine2 = cleanLine.replace(/^\(\d+\)/, '');
@@ -183,18 +198,26 @@ class PDFParserService {
const pin = pinMatch[1];
teamsPart = cleanLine3.substring(0, cleanLine3.length - pin.length).trim();
// PIN gehört zu dem Team, das direkt vor der PIN steht
// Analysiere die Position der PIN in der ursprünglichen Zeile
const pinIndex = cleanLine3.lastIndexOf(pin);
const teamsPartIndex = cleanLine3.indexOf(teamsPart);
// Die PIN gehört immer zu "Harheimer TC"
// Prüfe, ob "Harheimer TC" am Anfang oder am Ende steht
const harheimerIndex = teamsPart.indexOf('Harheimer TC');
// Wenn PIN direkt nach dem Teams-Part steht, gehört sie zur Heimmannschaft
// Wenn PIN zwischen den Teams steht, gehört sie zur Gastmannschaft
if (pinIndex === teamsPartIndex + teamsPart.length) {
// PIN steht direkt nach den Teams -> Heimmannschaft
homePin = pin;
if (harheimerIndex >= 0) {
// "Harheimer TC" gefunden
let beforeHarheimer = teamsPart.substring(0, harheimerIndex).trim();
// Entferne führende Spielnummer (z.B. "1", "2", etc.)
beforeHarheimer = beforeHarheimer.replace(/^\d+/, '').trim();
if (beforeHarheimer && beforeHarheimer.length > 0) {
// Es gibt einen Team-Namen vor "Harheimer TC" → Harheimer ist Gastteam → guestPin
guestPin = pin;
} else {
// "Harheimer TC" steht am Anfang (nur Spielnummer davor) → Harheimer ist Heimteam → homePin
homePin = pin;
}
} else {
// PIN steht zwischen den Teams -> Gastmannschaft
// "Harheimer TC" nicht gefunden → Standardlogik: PIN gehört zum Gastteam
guestPin = pin;
}
}
@@ -249,14 +272,41 @@ class PDFParserService {
} else {
// Fallback: Versuche mit einzelnen Leerzeichen zu trennen
// Strategie 1: Suche nach "Harheimer TC" als Heimteam
// Strategie 1: Suche nach "Harheimer TC" als Heimteam oder Gastteam
if (teamsPart.includes('Harheimer TC')) {
const harheimerIndex = teamsPart.indexOf('Harheimer TC');
homeTeamName = 'Harheimer TC';
guestTeamName = teamsPart.substring(harheimerIndex + 'Harheimer TC'.length).trim();
// Entferne Klammern aus Gastteam
guestTeamName = guestTeamName.replace(/\([^)]*\)/g, '').trim();
// Prüfe, ob "Harheimer TC" am Anfang oder am Ende steht
let beforeHarheimer = teamsPart.substring(0, harheimerIndex).trim();
let afterHarheimer = teamsPart.substring(harheimerIndex + 'Harheimer TC'.length).trim();
// Entferne Spielnummern aus beiden Teilen
beforeHarheimer = beforeHarheimer.replace(/^\d+/, '').trim();
afterHarheimer = afterHarheimer.replace(/^\d+/, '').trim();
if (beforeHarheimer && !afterHarheimer) {
// "Harheimer TC" ist am Ende → Harheimer ist Gastteam
guestTeamName = 'Harheimer TC';
homeTeamName = beforeHarheimer
.replace(/\([^)]*\)/g, '') // Entferne Klammern
.trim();
} else if (!beforeHarheimer && afterHarheimer) {
// "Harheimer TC" ist am Anfang → Harheimer ist Heimteam
homeTeamName = 'Harheimer TC';
guestTeamName = afterHarheimer
.replace(/\([^)]*\)/g, '') // Entferne Klammern
.trim();
} else if (beforeHarheimer && afterHarheimer) {
// "Harheimer TC" ist in der Mitte → verwende Position als Hinweis
// Normalerweise: Heimteam zuerst, dann Gastteam
homeTeamName = beforeHarheimer
.replace(/\([^)]*\)/g, '') // Entferne Klammern
.trim();
guestTeamName = 'Harheimer TC';
} else {
// Nur "Harheimer TC" ohne andere Teams → ungültig
continue;
}
} else {
// Strategie 2: Suche nach Großbuchstaben am Anfang des zweiten Teams
@@ -284,6 +334,8 @@ class PDFParserService {
debugInfo = `guestPin: "${guestPin}"`;
}
console.log(`[PDF Parser] Parsed match: ${homeTeamName} vs ${guestTeamName}, ${debugInfo}`);
matches.push({
date: date,
time: time,
@@ -554,40 +606,49 @@ class PDFParserService {
} else {
// Fallback: Versuche Teams direkt zu finden
const homeTeam = await Team.findOne({
let homeTeam = await Team.findOne({
where: {
name: matchData.homeTeamName,
clubId: matchData.clubId
}
});
const guestTeam = await Team.findOne({
let guestTeam = await Team.findOne({
where: {
name: matchData.guestTeamName,
clubId: matchData.clubId
}
});
// Debug: Zeige alle verfügbaren Teams für diesen Club
// If exact match failed, try fuzzy matching
if (!homeTeam || !guestTeam) {
const allTeams = await Team.findAll({
where: { clubId: matchData.clubId },
attributes: ['id', 'name']
});
console.log(`[PDF Parser] Available teams in club: ${allTeams.map(t => t.name).join(', ')}`);
// Versuche Fuzzy-Matching für Team-Namen
const homeTeamFuzzy = allTeams.find(t =>
t.name.toLowerCase().includes(matchData.homeTeamName.toLowerCase()) ||
matchData.homeTeamName.toLowerCase().includes(t.name.toLowerCase())
);
const guestTeamFuzzy = allTeams.find(t =>
t.name.toLowerCase().includes(matchData.guestTeamName.toLowerCase()) ||
matchData.guestTeamName.toLowerCase().includes(t.name.toLowerCase())
);
if (homeTeamFuzzy) {
// Fuzzy-Matching für Team-Namen
if (!homeTeam) {
homeTeam = allTeams.find(t =>
t.name.toLowerCase().includes(matchData.homeTeamName.toLowerCase()) ||
matchData.homeTeamName.toLowerCase().includes(t.name.toLowerCase())
);
if (homeTeam) {
console.log(`[PDF Parser] Found home team via fuzzy match: "${matchData.homeTeamName}" → "${homeTeam.name}"`);
}
}
if (guestTeamFuzzy) {
if (!guestTeam) {
guestTeam = allTeams.find(t =>
t.name.toLowerCase().includes(matchData.guestTeamName.toLowerCase()) ||
matchData.guestTeamName.toLowerCase().includes(t.name.toLowerCase())
);
if (guestTeam) {
console.log(`[PDF Parser] Found guest team via fuzzy match: "${matchData.guestTeamName}" → "${guestTeam.name}"`);
}
}
}

View File

@@ -1,5 +1,6 @@
import cron from 'node-cron';
import autoUpdateRatingsService from './autoUpdateRatingsService.js';
import autoFetchMatchResultsService from './autoFetchMatchResultsService.js';
import { devLog } from '../utils/logger.js';
class SchedulerService {
@@ -35,9 +36,26 @@ class SchedulerService {
this.jobs.set('ratingUpdates', ratingUpdateJob);
ratingUpdateJob.start();
// Schedule automatic match results fetching at 6:30 AM daily
const matchResultsJob = cron.schedule('30 6 * * *', async () => {
devLog('Executing scheduled match results fetch...');
try {
await autoFetchMatchResultsService.executeAutomaticFetch();
} catch (error) {
console.error('Error in scheduled match results fetch:', error);
}
}, {
scheduled: false, // Don't start automatically
timezone: 'Europe/Berlin'
});
this.jobs.set('matchResults', matchResultsJob);
matchResultsJob.start();
this.isRunning = true;
devLog('Scheduler service started successfully');
devLog('Rating updates scheduled for 6:00 AM daily (Europe/Berlin timezone)');
devLog('Match results fetch scheduled for 6:30 AM daily (Europe/Berlin timezone)');
}
/**
@@ -86,6 +104,20 @@ class SchedulerService {
}
}
/**
* Manually trigger match results fetch (for testing)
*/
async triggerMatchResultsFetch() {
devLog('Manually triggering match results fetch...');
try {
await autoFetchMatchResultsService.executeAutomaticFetch();
return { success: true, message: 'Match results fetch completed successfully' };
} catch (error) {
console.error('Error in manual match results fetch:', error);
return { success: false, message: error.message };
}
}
/**
* Get next scheduled execution time for rating updates
*/

View File

@@ -34,6 +34,7 @@
<th>Uhrzeit</th>
<th>Heimmannschaft</th>
<th>Gastmannschaft</th>
<th>Ergebnis</th>
<th v-if="selectedLeague === 'Gesamtspielplan' || selectedLeague === 'Spielplan Erwachsene'">Altersklasse</th>
<th>Code</th>
<th>Heim-PIN</th>
@@ -47,6 +48,12 @@
<td>{{ match.time ? match.time.toString().slice(0, 5) + ' Uhr' : 'N/A' }}</td>
<td v-html="highlightClubName(match.homeTeam?.name || 'N/A')"></td>
<td v-html="highlightClubName(match.guestTeam?.name || 'N/A')"></td>
<td class="result-cell" :class="getResultClass(match)">
<span v-if="match.isCompleted" class="result-score">
{{ match.homeMatchPoints }}:{{ match.guestMatchPoints }}
</span>
<span v-else class="result-pending"></span>
</td>
<td v-if="selectedLeague === 'Gesamtspielplan' || selectedLeague === 'Spielplan Erwachsene'">{{ match.leagueDetails?.name || 'N/A' }}</td>
<td class="code-cell">
<span v-if="match.code && selectedLeague && selectedLeague !== ''">
@@ -157,6 +164,34 @@ export default {
};
},
methods: {
getResultClass(match) {
if (!match.isCompleted) {
return '';
}
// Check if our club's team won or lost
const isOurTeamHome = this.isOurTeam(match.homeTeam?.name);
const isOurTeamGuest = this.isOurTeam(match.guestTeam?.name);
if (isOurTeamHome) {
// We are home team
return match.homeMatchPoints > match.guestMatchPoints ? 'completed won' : 'completed lost';
} else if (isOurTeamGuest) {
// We are guest team
return match.guestMatchPoints > match.homeMatchPoints ? 'completed won' : 'completed lost';
}
return 'completed';
},
isOurTeam(teamName) {
if (!teamName || !this.currentClubName) {
return false;
}
// Check if team name starts with our club name
return teamName.startsWith(this.currentClubName);
},
// Dialog Helper Methods
async showInfo(title, message, details = '', type = 'info') {
this.infoDialog = {
@@ -512,6 +547,36 @@ td {
white-space: nowrap;
}
.result-cell {
text-align: center;
font-weight: 600;
}
.result-score {
font-size: 1.1em;
}
.result-pending {
color: var(--text-muted);
font-style: italic;
}
.result-cell.completed.won {
background-color: #f0f9f0;
}
.result-cell.completed.won .result-score {
color: #28a745;
}
.result-cell.completed.lost {
background-color: #fff5f5;
}
.result-cell.completed.lost .result-score {
color: #dc3545;
}
.hover-info {
margin-top: 10px;
background-color: #eef;

View File

@@ -57,6 +57,62 @@
</button>
</div>
<!-- MyTischtennis URL Konfiguration -->
<div class="mytischtennis-config">
<div class="mytischtennis-header">
<h4>🏓 MyTischtennis Integration</h4>
<div class="header-actions">
<div v-if="teamToEdit" class="current-status">
<span v-if="getMyTischtennisStatus(teamToEdit).complete" class="status-badge complete">
Vollständig konfiguriert
</span>
<span v-else-if="getMyTischtennisStatus(teamToEdit).partial" class="status-badge partial">
{{ getMyTischtennisStatus(teamToEdit).missing }}
</span>
<span v-else class="status-badge missing">
Nicht konfiguriert
</span>
</div>
<button
v-if="teamToEdit && getMyTischtennisStatus(teamToEdit).complete"
@click="fetchTeamDataManually"
:disabled="fetchingTeamData"
class="fetch-btn"
title="Spielergebnisse und Statistiken von myTischtennis abrufen"
>
{{ fetchingTeamData ? '⏳ Abrufen...' : '🔄 Daten jetzt abrufen' }}
</button>
</div>
</div>
<div class="mytischtennis-url-input">
<label>
<span>MyTischtennis Team-URL einfügen und Enter drücken:</span>
<input
type="text"
v-model="myTischtennisUrl"
@keyup.enter="parseMyTischtennisUrl"
@blur="parseMyTischtennisUrl"
placeholder="https://www.mytischtennis.de/click-tt/..."
class="url-input"
:disabled="parsingUrl"
>
</label>
<div v-if="parsingUrl" class="parsing-indicator">
Konfiguriere automatisch...
</div>
</div>
<!-- Fehleranzeige -->
<div v-if="myTischtennisError" class="error-message">
{{ myTischtennisError }}
</div>
<!-- Erfolgsanzeige -->
<div v-if="myTischtennisSuccess" class="success-message">
{{ myTischtennisSuccess }}
</div>
</div>
<!-- Upload-Bestätigung -->
<div v-if="showLeagueSelection" class="upload-confirmation">
<div class="selected-file-info">
@@ -162,6 +218,20 @@
<span class="label">Erstellt:</span>
<span class="value">{{ formatDate(team.createdAt) }}</span>
</div>
<!-- MyTischtennis Status Badge -->
<div class="info-row mytischtennis-status">
<span class="label">🏓 MyTischtennis:</span>
<span v-if="getMyTischtennisStatus(team).complete" class="status-badge complete" :title="getMyTischtennisStatus(team).tooltip">
Vollständig konfiguriert
</span>
<span v-else-if="getMyTischtennisStatus(team).partial" class="status-badge partial" :title="getMyTischtennisStatus(team).tooltip">
Teilweise konfiguriert
</span>
<span v-else class="status-badge missing" :title="getMyTischtennisStatus(team).tooltip">
Nicht konfiguriert
</span>
</div>
</div>
<!-- PDF-Dokumente Icons -->
@@ -210,8 +280,6 @@
</div>
</div>
</div>
</div>
<!-- Info Dialog -->
<InfoDialog
@@ -232,6 +300,7 @@
@confirm="handleConfirmResult(true)"
@cancel="handleConfirmResult(false)"
/>
</div>
</template>
<script>
@@ -289,6 +358,15 @@ export default {
const pdfUrl = ref('');
const pdfDialogTitle = ref('');
// MyTischtennis URL Parser
const myTischtennisUrl = ref('');
const parsedMyTischtennisData = ref(null);
const parsingUrl = ref(false);
const configuringTeam = ref(false);
const fetchingTeamData = ref(false);
const myTischtennisError = ref('');
const myTischtennisSuccess = ref('');
// Computed
const selectedClub = computed(() => store.state.currentClub);
const authToken = computed(() => store.state.token);
@@ -540,12 +618,18 @@ export default {
parseResult.debugInfo.lastFewLines.forEach((line, index) => {
message += `${parseResult.debugInfo.totalLines - 5 + index + 1}: "${line}"\n`;
});
// Fehler-Dialog wenn nichts gefunden wurde
await showInfo('Fehler', message, '', 'error');
} else if (saveResult.errors.length > 0) {
// Warnung wenn Spiele gefunden wurden, aber Fehler auftraten
await showInfo('Warnung', message, '', 'warning');
} else {
// Erfolg wenn alles geklappt hat
await showInfo('Erfolg', message, '', 'success');
}
this.showInfo('Fehler', message, '', 'error');
} else {
// Für andere Dateitypen nur Upload-Bestätigung
this.showInfo('Information', `${pendingUploadType.value === 'code_list' ? 'Code-Liste' : 'Pin-Liste'} "${pendingUploadFile.value.name}" wurde erfolgreich hochgeladen!`, '', 'info');
await showInfo('Information', `${pendingUploadType.value === 'code_list' ? 'Code-Liste' : 'Pin-Liste'} "${pendingUploadFile.value.name}" wurde erfolgreich hochgeladen!`, '', 'info');
}
// Dokumente neu laden
@@ -553,7 +637,7 @@ export default {
} catch (error) {
console.error('Fehler beim Hochladen und Parsen der Datei:', error);
this.showInfo('Fehler', 'Fehler beim Hochladen und Parsen der Datei', '', 'error');
await showInfo('Fehler', 'Fehler beim Hochladen und Parsen der Datei', '', 'error');
} finally {
parsingInProgress.value = false;
pendingUploadFile.value = null;
@@ -695,6 +779,175 @@ export default {
confirmDialog.value.isOpen = false;
};
// MyTischtennis URL Parser Methods
const parseMyTischtennisUrl = async () => {
if (!myTischtennisUrl.value || !myTischtennisUrl.value.trim()) {
return;
}
parsingUrl.value = true;
myTischtennisError.value = '';
myTischtennisSuccess.value = '';
parsedMyTischtennisData.value = null;
try {
const response = await apiClient.post('/mytischtennis/parse-url', {
url: myTischtennisUrl.value.trim()
});
if (response.data.success) {
parsedMyTischtennisData.value = response.data.data;
// Automatisch konfigurieren
await configureTeamFromUrl();
}
} catch (error) {
console.error('Fehler beim Parsen der URL:', error);
myTischtennisError.value = error.response?.data?.message || 'URL konnte nicht geparst werden. Bitte überprüfen Sie das Format.';
await showInfo('Fehler', myTischtennisError.value, '', 'error');
} finally {
parsingUrl.value = false;
}
};
const configureTeamFromUrl = async () => {
if (!parsedMyTischtennisData.value || !teamToEdit.value) {
return;
}
configuringTeam.value = true;
myTischtennisError.value = '';
myTischtennisSuccess.value = '';
try {
const response = await apiClient.post('/mytischtennis/configure-team', {
url: myTischtennisUrl.value.trim(),
clubTeamId: teamToEdit.value.id,
createLeague: true,
createSeason: true
});
if (response.data.success) {
myTischtennisSuccess.value = 'Team erfolgreich konfiguriert! Automatischer Datenabruf ist jetzt aktiv.';
await showInfo(
'Erfolg',
'Team erfolgreich konfiguriert!',
`Liga: ${response.data.data.league.name}\nSaison: ${response.data.data.season.name}\nAutomatischer Datenabruf ist jetzt aktiv.`,
'success'
);
// Teams neu laden
await loadTeams();
// Parsed Data löschen
clearParsedData();
}
} catch (error) {
console.error('Fehler bei der Konfiguration:', error);
myTischtennisError.value = error.response?.data?.message || 'Team konnte nicht konfiguriert werden.';
await showInfo('Fehler', myTischtennisError.value, '', 'error');
} finally {
configuringTeam.value = false;
}
};
const clearParsedData = () => {
parsedMyTischtennisData.value = null;
myTischtennisUrl.value = '';
myTischtennisError.value = '';
myTischtennisSuccess.value = '';
};
const getMyTischtennisStatus = (team) => {
if (!team) {
return { complete: false, partial: false, missing: '', tooltip: '' };
}
const hasTeamId = !!team.myTischtennisTeamId;
const hasLeague = !!team.league;
const hasLeagueConfig = hasLeague &&
!!team.league.myTischtennisGroupId &&
!!team.league.association &&
!!team.league.groupname;
const missingItems = [];
if (!hasTeamId) missingItems.push('Team-ID');
if (!hasLeague) missingItems.push('Liga');
if (hasLeague && !team.league.myTischtennisGroupId) missingItems.push('Gruppen-ID');
if (hasLeague && !team.league.association) missingItems.push('Verband');
if (hasLeague && !team.league.groupname) missingItems.push('Gruppenname');
const complete = hasTeamId && hasLeagueConfig;
const partial = (hasTeamId || hasLeagueConfig) && !complete;
let tooltip = '';
if (complete) {
tooltip = 'Automatischer Datenabruf ist aktiviert';
} else if (partial) {
tooltip = `Fehlend: ${missingItems.join(', ')}`;
} else {
tooltip = 'MyTischtennis-URL eingeben für automatische Konfiguration';
}
return {
complete,
partial,
missing: missingItems.length > 0 ? `Fehlt: ${missingItems.join(', ')}` : '',
tooltip
};
};
const fetchTeamDataManually = async () => {
if (!teamToEdit.value || !teamToEdit.value.id) {
return;
}
fetchingTeamData.value = true;
myTischtennisError.value = '';
myTischtennisSuccess.value = '';
try {
const response = await apiClient.post('/mytischtennis/fetch-team-data', {
clubTeamId: teamToEdit.value.id
});
if (response.data.success) {
myTischtennisSuccess.value = response.data.message;
await showInfo(
'Erfolg',
response.data.message,
`Team: ${response.data.data.teamName}\nAbgerufene Datensätze: ${response.data.data.fetchedCount}`,
'success'
);
}
} catch (error) {
console.error('Fehler beim Abrufen der Team-Daten:', error);
const errorMsg = error.response?.data?.message || 'Daten konnten nicht abgerufen werden.';
myTischtennisError.value = errorMsg;
// Spezielle Behandlung für Account-nicht-verknüpft Fehler
if (error.response?.status === 404) {
await showInfo(
'MyTischtennis-Account nicht verknüpft',
errorMsg,
'Gehen Sie zu den MyTischtennis-Einstellungen, um Ihren Account zu verknüpfen.',
'warning'
);
} else if (error.response?.status === 401) {
await showInfo(
'Login erforderlich',
errorMsg,
'Aktivieren Sie "Passwort speichern" in den MyTischtennis-Einstellungen für automatischen Login.',
'warning'
);
} else {
await showInfo('Fehler', errorMsg, '', 'error');
}
} finally {
fetchingTeamData.value = false;
}
};
return {
infoDialog,
confirmDialog,
@@ -717,6 +970,13 @@ export default {
showPDFViewer,
pdfUrl,
pdfDialogTitle,
myTischtennisUrl,
parsedMyTischtennisData,
parsingUrl,
configuringTeam,
fetchingTeamData,
myTischtennisError,
myTischtennisSuccess,
filteredLeagues,
toggleNewTeam,
resetToNewTeam,
@@ -737,7 +997,12 @@ export default {
closePDFDialog,
formatDate,
formatFileSize,
onSeasonChange
onSeasonChange,
parseMyTischtennisUrl,
configureTeamFromUrl,
clearParsedData,
getMyTischtennisStatus,
fetchTeamDataManually
};
}
};
@@ -1379,4 +1644,170 @@ export default {
min-width: auto;
}
}
/* MyTischtennis URL Parser Styles */
.mytischtennis-config {
margin-top: 1.5rem;
padding: 1rem;
background: var(--background-light);
border-radius: var(--border-radius-medium);
border: 1px solid var(--border-color);
}
.mytischtennis-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
gap: 1rem;
}
.mytischtennis-config h4 {
margin: 0;
color: var(--primary-color);
font-size: 1.1rem;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
}
.current-status {
flex-shrink: 0;
}
.fetch-btn {
padding: 0.5rem 1rem;
background: #2196F3;
color: white;
border: none;
border-radius: var(--border-radius-small);
cursor: pointer;
font-weight: 600;
font-size: 0.85rem;
transition: var(--transition);
white-space: nowrap;
}
.fetch-btn:hover:not(:disabled) {
background: #1976D2;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(33, 150, 243, 0.3);
}
.fetch-btn:disabled {
background: var(--text-muted);
cursor: not-allowed;
opacity: 0.5;
}
.status-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: var(--border-radius-small);
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
}
.status-badge.complete {
background: #e8f5e9;
color: #2e7d32;
border: 1px solid #4caf50;
}
.status-badge.partial {
background: #fff3e0;
color: #e65100;
border: 1px solid #ff9800;
}
.status-badge.missing {
background: #ffebee;
color: #c62828;
border: 1px solid #ef5350;
}
.mytischtennis-status {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border-color);
}
.mytischtennis-status .status-badge {
font-size: 0.8rem;
padding: 0.2rem 0.6rem;
}
.mytischtennis-url-input {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.mytischtennis-url-input label {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.mytischtennis-url-input span {
font-weight: 600;
font-size: 0.9rem;
color: var(--text-secondary);
}
.url-input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-small);
font-size: 0.9rem;
}
.url-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.1);
}
.url-input:disabled {
background: var(--background-light);
cursor: wait;
}
.parsing-indicator {
padding: 0.5rem;
background: #e3f2fd;
border: 1px solid #2196F3;
border-radius: var(--border-radius-small);
color: #1976D2;
font-size: 0.9rem;
text-align: center;
font-weight: 600;
}
.error-message {
margin-top: 1rem;
padding: 0.75rem;
background: #ffebee;
border: 1px solid #ef5350;
border-radius: var(--border-radius-small);
color: #c62828;
font-size: 0.9rem;
}
.success-message {
margin-top: 1rem;
padding: 0.75rem;
background: #e8f5e9;
border: 1px solid #66bb6a;
border-radius: var(--border-radius-small);
color: #2e7d32;
font-size: 0.9rem;
}
</style>