From 1517d83f6c3ba84df4adfe95b34a30f0bcda18f1 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Tue, 14 Oct 2025 21:58:21 +0200 Subject: [PATCH] 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. --- .cursor/commands/oldfkchecks.md | 0 backend/MYTISCHTENNIS_AUTO_FETCH_README.md | 212 ++ backend/MYTISCHTENNIS_URL_PARSER_README.md | 328 +++ .../controllers/myTischtennisUrlController.js | 394 ++++ .../migrations/add_match_result_fields.sql | 28 + .../add_mytischtennis_fields_to_league.sql | 19 + .../add_mytischtennis_player_id_to_member.sql | 11 + ...add_mytischtennis_team_id_to_club_team.sql | 11 + .../make_location_optional_in_match.sql | 8 + backend/models/ClubTeam.js | 6 + backend/models/League.js | 16 + backend/models/Match.js | 36 +- backend/models/Member.js | 6 + backend/models/index.js | 3 + backend/package.json | 2 +- backend/routes/myTischtennisRoutes.js | 13 + backend/server.js | 4 +- backend/server.log | 1778 +++++++++++++++++ .../services/autoFetchMatchResultsService.js | 657 ++++++ backend/services/clubTeamService.js | 5 +- backend/services/matchService.js | 137 +- backend/services/memberService.js | 2 +- .../services/myTischtennisUrlParserService.js | 245 +++ backend/services/pdfParserService.js | 131 +- backend/services/schedulerService.js | 32 + .../10_code_list_1760455939125.pdf | Bin 0 -> 27910 bytes .../10_pin_list_1760455950179.pdf | Bin 0 -> 6946 bytes .../1_code_list_1760470543608.pdf | Bin 0 -> 27910 bytes .../1_pin_list_1760471862054.pdf | Bin 0 -> 6946 bytes frontend/src/views/ScheduleView.vue | 65 + frontend/src/views/TeamManagementView.vue | 445 ++++- 31 files changed, 4538 insertions(+), 56 deletions(-) create mode 100644 .cursor/commands/oldfkchecks.md create mode 100644 backend/MYTISCHTENNIS_AUTO_FETCH_README.md create mode 100644 backend/MYTISCHTENNIS_URL_PARSER_README.md create mode 100644 backend/controllers/myTischtennisUrlController.js create mode 100644 backend/migrations/add_match_result_fields.sql create mode 100644 backend/migrations/add_mytischtennis_fields_to_league.sql create mode 100644 backend/migrations/add_mytischtennis_player_id_to_member.sql create mode 100644 backend/migrations/add_mytischtennis_team_id_to_club_team.sql create mode 100644 backend/migrations/make_location_optional_in_match.sql create mode 100644 backend/server.log create mode 100644 backend/services/autoFetchMatchResultsService.js create mode 100644 backend/services/myTischtennisUrlParserService.js create mode 100644 backend/uploads/team-documents/10_code_list_1760455939125.pdf create mode 100644 backend/uploads/team-documents/10_pin_list_1760455950179.pdf create mode 100644 backend/uploads/team-documents/1_code_list_1760470543608.pdf create mode 100644 backend/uploads/team-documents/1_pin_list_1760471862054.pdf diff --git a/.cursor/commands/oldfkchecks.md b/.cursor/commands/oldfkchecks.md new file mode 100644 index 0000000..e69de29 diff --git a/backend/MYTISCHTENNIS_AUTO_FETCH_README.md b/backend/MYTISCHTENNIS_AUTO_FETCH_README.md new file mode 100644 index 0000000..bf68d3c --- /dev/null +++ b/backend/MYTISCHTENNIS_AUTO_FETCH_README.md @@ -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) + diff --git a/backend/MYTISCHTENNIS_URL_PARSER_README.md b/backend/MYTISCHTENNIS_URL_PARSER_README.md new file mode 100644 index 0000000..a54eff9 --- /dev/null +++ b/backend/MYTISCHTENNIS_URL_PARSER_README.md @@ -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 + + + +``` + +## 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 + diff --git a/backend/controllers/myTischtennisUrlController.js b/backend/controllers/myTischtennisUrlController.js new file mode 100644 index 0000000..3b97b2e --- /dev/null +++ b/backend/controllers/myTischtennisUrlController.js @@ -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(); diff --git a/backend/migrations/add_match_result_fields.sql b/backend/migrations/add_match_result_fields.sql new file mode 100644 index 0000000..1671acd --- /dev/null +++ b/backend/migrations/add_match_result_fields.sql @@ -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); + diff --git a/backend/migrations/add_mytischtennis_fields_to_league.sql b/backend/migrations/add_mytischtennis_fields_to_league.sql new file mode 100644 index 0000000..949fbb2 --- /dev/null +++ b/backend/migrations/add_mytischtennis_fields_to_league.sql @@ -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); + diff --git a/backend/migrations/add_mytischtennis_player_id_to_member.sql b/backend/migrations/add_mytischtennis_player_id_to_member.sql new file mode 100644 index 0000000..35bf42d --- /dev/null +++ b/backend/migrations/add_mytischtennis_player_id_to_member.sql @@ -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); + diff --git a/backend/migrations/add_mytischtennis_team_id_to_club_team.sql b/backend/migrations/add_mytischtennis_team_id_to_club_team.sql new file mode 100644 index 0000000..56812e6 --- /dev/null +++ b/backend/migrations/add_mytischtennis_team_id_to_club_team.sql @@ -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); + diff --git a/backend/migrations/make_location_optional_in_match.sql b/backend/migrations/make_location_optional_in_match.sql new file mode 100644 index 0000000..cb7e62a --- /dev/null +++ b/backend/migrations/make_location_optional_in_match.sql @@ -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; + diff --git a/backend/models/ClubTeam.js b/backend/models/ClubTeam.js index fb84cc1..412268e 100644 --- a/backend/models/ClubTeam.js +++ b/backend/models/ClubTeam.js @@ -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', diff --git a/backend/models/League.js b/backend/models/League.js index 12c8ac7..54b0e6b 100644 --- a/backend/models/League.js +++ b/backend/models/League.js @@ -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', diff --git a/backend/models/Match.js b/backend/models/Match.js index ee385e1..b05d57b 100644 --- a/backend/models/Match.js +++ b/backend/models/Match.js @@ -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', diff --git a/backend/models/Member.js b/backend/models/Member.js index a415712..7ca45ba 100644 --- a/backend/models/Member.js +++ b/backend/models/Member.js @@ -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, diff --git a/backend/models/index.js b/backend/models/index.js index 86ec207..b82ca12 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -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' }); diff --git a/backend/package.json b/backend/package.json index 9a6c9fa..2ea54d8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/routes/myTischtennisRoutes.js b/backend/routes/myTischtennisRoutes.js index 1d02f3b..226bb5c 100644 --- a/backend/routes/myTischtennisRoutes.js +++ b/backend/routes/myTischtennisRoutes.js @@ -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; diff --git a/backend/server.js b/backend/server.js index ac74e26..905d702 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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); diff --git a/backend/server.log b/backend/server.log new file mode 100644 index 0000000..f2bf87f --- /dev/null +++ b/backend/server.log @@ -0,0 +1,1778 @@ + +> backend@1.0.0 dev +> nodemon server.js + +[nodemon] 3.1.4 +[nodemon] to restart at any time, enter `rs` +[nodemon] watching path(s): *.* +[nodemon] watching extensions: js,mjs,cjs,json +[nodemon] starting `node server.js` +Starting scheduler service... +Scheduler service started successfully +Rating updates scheduled for 6:00 AM daily (Europe/Berlin timezone) +Match results fetch scheduled for 6:30 AM daily (Europe/Berlin timezone) +Server is running on http://localhost:3000 +Scheduler service started: + - Rating updates: 6:00 AM daily + - Match results fetch: 6:30 AM daily +Fetching session for userId: 1 (from header: tsschulz@gmx.net ) +Session found: true +Using session: { + email: 'tsschulz@gmx.net', + hasCookie: true, + hasAccessToken: true, + expiresAt: 2025-10-14T18:58:15.000Z +} +Team data: { + id: 1, + name: 'J11', + myTischtennisTeamId: '2995094', + hasLeague: true, + leagueData: { + id: 5, + name: '1.Kreisklasse', + myTischtennisGroupId: '504417', + association: 'HeTTV', + groupname: '1.Kreisklasse', + hasSeason: true + } +} +=== FETCH TEAM RESULTS === +Team name (from ClubTeam): J11 +Team name encoded: J11 +MyTischtennis Team ID: 2995094 +Fetching player stats from: https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/J11/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 +=== PLAYER STATS RESPONSE START === +{ + "data": { + "head_infos": { + "region": "Frankfurt", + "season": "25/26", + "club_name": "Harheimer TC", + "team_name": "Jugend 11", + "league_name": "Jugend 13 1. Kreisklasse ", + "club_image_url": null, + "team_table_rank": 8, + "team_matches_won": 0, + "team_matches_lost": 3, + "organization_short": "HeTTV" + }, + "balancesheet": [ + { + "club_id": "43030", + "team_id": "2995094", + "group_id": "504417", + "club_name": "Harheimer TC", + "team_name": "Jugend 11", + "league_name": "Jugend 13 1. Kreisklasse ", + "team_total_points_won": 1, + "team_double_points_won": 0, + "team_single_points_won": 1, + "team_total_points_lost": 29, + "team_double_points_lost": 3, + "team_single_points_lost": 26, + "double_player_statistics": [ + { + "points_won": "0", + "id_player_1": "NU2707420", + "id_player_2": "NU2742419", + "points_lost": "1", + "meeting_count": "1", + "lastname_player_1": "Völker", + "lastname_player_2": "Rusu Cara", + "firstname_player_1": "Emilian", + "firstname_player_2": "Daniel" + }, + { + "points_won": "0", + "id_player_1": "NU2705037", + "id_player_2": "NU2707420", + "points_lost": "1", + "meeting_count": "1", + "lastname_player_1": "Wolf", + "lastname_player_2": "Völker", + "firstname_player_1": "Timo", + "firstname_player_2": "Emilian" + }, + { + "points_won": "0", + "id_player_1": "NU2705037", + "id_player_2": "NU2742427", + "points_lost": "1", + "meeting_count": "1", + "lastname_player_1": "Wolf", + "lastname_player_2": "Koch", + "firstname_player_1": "Timo", + "firstname_player_2": "Joschua" + } + ], + "single_player_statistics": [ + { + "player_id": "NU2705037", + "points_won": "1", + "player_rank": "1", + "points_lost": "5", + "team_number": "1", + "meeting_count": "2", + "player_lastname": "Wolf", + "player_firstname": "Timo", + "single_statistics": [ + { + "points_won": "1", + "points_lost": "1", + "opponent_rank": "1" + }, + { + "points_won": "0", + "points_lost": "2", + "opponent_rank": "2" + }, + { + "points_won": "0", + "points_lost": "2", + "opponent_rank": "3" + } + ] + }, + { + "player_id": "NU2707420", + "points_won": "0", + "player_rank": "2", + "points_lost": "5", + "team_number": "1", + "meeting_count": "2", + "player_lastname": "Völker", + "player_firstname": "Emilian", + "single_statistics": [ + { + "points_won": "0", + "points_lost": "2", + "opponent_rank": "1" + }, + { + "points_won": "0", + "points_lost": "2", + "opponent_rank": "2" + }, + { + "points_won": "0", + "points_lost": "1", + "opponent_rank": "3" + } + ] + }, + { + "player_id": "NU2742420", + "points_won": "0", + "player_rank": "3", + "points_lost": "2", + "team_number": "1", + "meeting_count": "1", + "player_lastname": "Rusu Cara", + "player_firstname": "Lukas", + "single_statistics": [ + { + "points_won": "0", + "points_lost": "1", + "opponent_rank": "1" + }, + { + "points_won": "0", + "points_lost": "1", + "opponent_rank": "2" + } + ] + }, + { + "player_id": "NU2742419", + "points_won": "0", + "player_rank": "4", + "points_lost": "2", + "team_number": "1", + "meeting_count": "1", + "player_lastname": "Rusu Cara", + "player_firstname": "Daniel", + "single_statistics": [ + { + "points_won": "0", + "points_lost": "1", + "opponent_rank": "1" + }, + { + "points_won": "0", + "points_lost": "1", + "opponent_rank": "3" + } + ] + }, + { + "player_id": "NU2742427", + "points_won": "0", + "player_rank": "5", + "points_lost": "7", + "team_number": "1", + "meeting_count": "3", + "player_lastname": "Koch", + "player_firstname": "Joschua", + "single_statistics": [ + { + "points_won": "0", + "points_lost": "2", + "opponent_rank": "1" + }, + { + "points_won": "0", + "points_lost": "2", + "opponent_rank": "2" + }, + { + "points_won": "0", + "points_lost": "2", + "opponent_rank": "3" + }, + { + "points_won": "0", + "points_lost": "1", + "opponent_rank": "4" + } + ] + }, + { + "player_id": "NU2742422", + "points_won": "0", + "player_rank": "6", + "points_lost": "2", + "team_number": "1", + "meeting_count": "1", + "player_lastname": "Swyter", + "player_firstname": "Fred", + "single_statistics": [ + { + "points_won": "0", + "points_lost": "0", + "opponent_rank": "1" + }, + { + "points_won": "0", + "points_lost": "1", + "opponent_rank": "2" + }, + { + "points_won": "0", + "points_lost": "1", + "opponent_rank": "3" + } + ] + } + ] + } + ] + }, + "season": "25--26", + "season_filter": "entire", + "association": "HeTTV", + "groupname": "1.Kreisklasse", + "urlid": "504417", + "teamid": "2995094", + "teamname": "J11", + "tableData": { + "table": [ + { + "club_id": "43009", + "team_id": "2995465", + "sets_won": 83, + "tendency": "steady", + "games_won": 1063, + "sets_lost": 19, + "team_name": "Eintracht Frankfurt V", + "games_lost": 696, + "points_won": 6, + "table_rank": 1, + "matches_won": 26, + "points_lost": 0, + "matches_lost": 4, + "meetings_tie": 0, + "meetings_won": 3, + "meetings_lost": 0, + "sets_relation": "+64", + "games_relation": "+367", + "meetings_count": 3, + "rise_fall_state": null, + "matches_relation": "+22" + }, + { + "club_id": "43009", + "team_id": "2995463", + "sets_won": 65, + "tendency": "steady", + "games_won": 995, + "sets_lost": 42, + "team_name": "Eintracht Frankfurt IV", + "games_lost": 842, + "points_won": 4, + "table_rank": 2, + "matches_won": 20, + "points_lost": 2, + "matches_lost": 10, + "meetings_tie": 0, + "meetings_won": 2, + "meetings_lost": 1, + "sets_relation": "+23", + "games_relation": "+153", + "meetings_count": 3, + "rise_fall_state": null, + "matches_relation": "+10" + }, + { + "club_id": "43041", + "team_id": "2985175", + "sets_won": 49, + "tendency": "steady", + "games_won": 739, + "sets_lost": 24, + "team_name": "TSG Oberrad II", + "games_lost": 612, + "points_won": 3, + "table_rank": 3, + "matches_won": 15, + "points_lost": 1, + "matches_lost": 5, + "meetings_tie": 1, + "meetings_won": 1, + "meetings_lost": 0, + "sets_relation": "+25", + "games_relation": "+127", + "meetings_count": 2, + "rise_fall_state": null, + "matches_relation": "+10" + }, + { + "club_id": "43009", + "team_id": "2995468", + "sets_won": 54, + "tendency": "steady", + "games_won": 1129, + "sets_lost": 84, + "team_name": "Eintracht Frankfurt II (J11)", + "games_lost": 1268, + "points_won": 3, + "table_rank": 4, + "matches_won": 14, + "points_lost": 5, + "matches_lost": 26, + "meetings_tie": 1, + "meetings_won": 1, + "meetings_lost": 2, + "sets_relation": "-30", + "games_relation": "-139", + "meetings_count": 4, + "rise_fall_state": null, + "matches_relation": "-12" + }, + { + "club_id": "43048", + "team_id": "2994011", + "sets_won": 43, + "tendency": "steady", + "games_won": 779, + "sets_lost": 37, + "team_name": "TV Seckbach 1875", + "games_lost": 749, + "points_won": 2, + "table_rank": 5, + "matches_won": 10, + "points_lost": 2, + "matches_lost": 10, + "meetings_tie": 2, + "meetings_won": 0, + "meetings_lost": 0, + "sets_relation": "+6", + "games_relation": "+30", + "meetings_count": 2, + "rise_fall_state": null, + "matches_relation": "0" + }, + { + "club_id": "43039", + "team_id": "2987226", + "sets_won": 35, + "tendency": "steady", + "games_won": 664, + "sets_lost": 38, + "team_name": "TSG Nieder-Erlenbach", + "games_lost": 708, + "points_won": 2, + "table_rank": 6, + "matches_won": 10, + "points_lost": 2, + "matches_lost": 10, + "meetings_tie": 2, + "meetings_won": 0, + "meetings_lost": 0, + "sets_relation": "-3", + "games_relation": "-44", + "meetings_count": 2, + "rise_fall_state": null, + "matches_relation": "0" + }, + { + "club_id": "43037", + "team_id": "2994841", + "sets_won": 49, + "tendency": "steady", + "games_won": 819, + "sets_lost": 53, + "team_name": "TV Niederrad III", + "games_lost": 846, + "points_won": 2, + "table_rank": 7, + "matches_won": 14, + "points_lost": 4, + "matches_lost": 16, + "meetings_tie": 0, + "meetings_won": 1, + "meetings_lost": 2, + "sets_relation": "-4", + "games_relation": "-27", + "meetings_count": 3, + "rise_fall_state": null, + "matches_relation": "-2" + }, + { + "club_id": "43030", + "team_id": "2995094", + "sets_won": 8, + "tendency": "steady", + "games_won": 597, + "sets_lost": 89, + "team_name": "Harheimer TC (J11)", + "games_lost": 1064, + "points_won": 0, + "table_rank": 8, + "matches_won": 1, + "points_lost": 6, + "matches_lost": 29, + "meetings_tie": 0, + "meetings_won": 0, + "meetings_lost": 3, + "sets_relation": "-81", + "games_relation": "-467", + "meetings_count": 3, + "rise_fall_state": null, + "matches_relation": "-28" + } + ], + "head_infos": { + "region": "Frankfurt", + "season": "25/26", + "club_name": "Harheimer TC", + "team_name": "Jugend 11", + "league_name": "Jugend 13 1. Kreisklasse ", + "club_image_url": null, + "team_table_rank": 8, + "team_matches_won": 0, + "team_matches_lost": 3, + "organization_short": "HeTTV" + }, + "no_meetings": false, + "meetings_excerpt": { + "remarks": null, + "meetings": [ + { + "2025-09-06": [ + { + "date": "2025-09-06T08:00:00.000+00:00", + "live": false, + "state": "done", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440505&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "Harheimer TC (J11)", + "team_home": "TSG Oberrad II", + "meeting_id": "15440505", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "10", + "is_confirmed": true, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2995094", + "team_home_id": "2985175", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43030", + "team_home_club_id": "43041", + "is_meeting_complete": true, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-09-28": [ + { + "date": "2025-09-28T08:30:00.000+00:00", + "live": false, + "state": "done", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440495&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "Harheimer TC (J11)", + "team_home": "TV Niederrad III", + "meeting_id": "15440495", + "round_name": null, + "round_type": "0", + "hall_number": "2", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "9", + "is_confirmed": true, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "1", + "team_away_id": "2995094", + "team_home_id": "2994841", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43030", + "team_home_club_id": "43037", + "is_meeting_complete": true, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-09-30": [ + { + "date": "2025-09-30T16:00:00.000+00:00", + "live": false, + "state": "done", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440488&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "Eintracht Frankfurt V", + "team_home": "Harheimer TC (J11)", + "meeting_id": "15440488", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": true, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "10", + "team_away_id": "2995465", + "team_home_id": "2995094", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43009", + "team_home_club_id": "43030", + "is_meeting_complete": true, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-10-25": [ + { + "date": "2025-10-25T11:15:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440502&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "Harheimer TC (J11)", + "team_home": "Eintracht Frankfurt IV", + "meeting_id": "15440502", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2995094", + "team_home_id": "2995463", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43030", + "team_home_club_id": "43009", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-11-11": [ + { + "date": "2025-11-11T17:00:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440500&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "TSG Nieder-Erlenbach", + "team_home": "Harheimer TC (J11)", + "meeting_id": "15440500", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2987226", + "team_home_id": "2995094", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43039", + "team_home_club_id": "43030", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-11-29": [ + { + "date": "2025-11-29T12:15:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440508&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "Harheimer TC (J11)", + "team_home": "Eintracht Frankfurt II (J11)", + "meeting_id": "15440508", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2995094", + "team_home_id": "2995468", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43030", + "team_home_club_id": "43009", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-12-09": [ + { + "date": "2025-12-09T17:00:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440497&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "TV Seckbach 1875", + "team_home": "Harheimer TC (J11)", + "meeting_id": "15440497", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2994011", + "team_home_id": "2995094", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43048", + "team_home_club_id": "43030", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2026-02-06": [ + { + "date": "2026-02-06T17:00:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440515&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "Harheimer TC (J11)", + "team_home": "TV Seckbach 1875", + "meeting_id": "15440515", + "round_name": null, + "round_type": "1", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2995094", + "team_home_id": "2994011", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43030", + "team_home_club_id": "43048", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2026-02-16": [ + { + "date": "2026-02-16T17:00:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440522&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "Harheimer TC (J11)", + "team_home": "TSG Nieder-Erlenbach", + "meeting_id": "15440522", + "round_name": null, + "round_type": "1", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2995094", + "team_home_id": "2987226", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43030", + "team_home_club_id": "43039", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2026-02-24": [ + { + "date": "2026-02-24T17:00:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440524&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "TV Niederrad III", + "team_home": "Harheimer TC (J11)", + "meeting_id": "15440524", + "round_name": null, + "round_type": "1", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2994841", + "team_home_id": "2995094", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43037", + "team_home_club_id": "43030", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2026-03-10": [ + { + "date": "2026-03-10T17:00:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440532&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "TSG Oberrad II", + "team_home": "Harheimer TC (J11)", + "meeting_id": "15440532", + "round_name": null, + "round_type": "1", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2985175", + "team_home_id": "2995094", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43041", + "team_home_club_id": "43030", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2026-03-24": [ + { + "date": "2026-03-24T17:00:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440537&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "Eintracht Frankfurt IV", + "team_home": "Harheimer TC (J11)", + "meeting_id": "15440537", + "round_name": null, + "round_type": "1", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2995463", + "team_home_id": "2995094", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43009", + "team_home_club_id": "43030", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2026-04-18": [ + { + "date": "2026-04-18T08:00:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440520&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "Harheimer TC (J11)", + "team_home": "Eintracht Frankfurt V", + "meeting_id": "15440520", + "round_name": null, + "round_type": "1", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2995094", + "team_home_id": "2995465", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43030", + "team_home_club_id": "43009", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2026-04-21": [ + { + "date": "2026-04-21T16:00:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440518&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "Eintracht Frankfurt II (J11)", + "team_home": "Harheimer TC (J11)", + "meeting_id": "15440518", + "round_name": null, + "round_type": "1", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2995468", + "team_home_id": "2995094", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43009", + "team_home_club_id": "43030", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + } + ], + "pdf_version_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=ScheduleReportFOP&group=504417", + "pdf_materials_url": null + } + }, + "tableError": null +} +=== PLAYER STATS RESPONSE END === +Processing data for team J11 +Player: Timo Wolf (ID: NU2705037) + Points won: 1, Points lost: 5 + No local member found for Timo Wolf +Player: Emilian Völker (ID: NU2707420) + Points won: 0, Points lost: 5 + No local member found for Emilian Völker +Player: Lukas Rusu Cara (ID: NU2742420) + Points won: 0, Points lost: 2 + No local member found for Lukas Rusu Cara +Player: Daniel Rusu Cara (ID: NU2742419) + Points won: 0, Points lost: 2 + No local member found for Daniel Rusu Cara +Player: Joschua Koch (ID: NU2742427) + Points won: 0, Points lost: 7 + Matched with local member: Joschua Koch (ID: 2) +Player: Fred Swyter (ID: NU2742422) + Points won: 0, Points lost: 2 + No local member found for Fred Swyter +Double: Emilian Völker / Daniel Rusu Cara + Points won: 0, Points lost: 1 +Double: Timo Wolf / Emilian Völker + Points won: 0, Points lost: 1 +Double: Timo Wolf / Joschua Koch + Points won: 0, Points lost: 1 +Processed 9 player statistics +Fetching match results from: https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/tabelle/gesamt?_data=routes%2Fclick-tt%2B%2F%24association%2B%2F%24season%2B%2F%24type%2B%2F%24groupname.gruppe.%24urlid%2B%2F_layout +=== MATCH RESULTS RESPONSE START === +{ + "seasonStatus": "PUBLIC", + "seasonType": "", + "data": { + "head_infos": { + "season": "25/26", + "play_mode": "Braunschweiger System", + "league_name": "Jugend 13 1. Kreisklasse ", + "championship": "K43 25/26", + "gender_age_group": "Jugend 13", + "organization_short": "HeTTV" + }, + "no_meetings": false, + "season_list": [ + { + "name": "04/05", + "full_name": "2004/05" + }, + { + "name": "05/06", + "full_name": "2005/06" + }, + { + "name": "06/07", + "full_name": "2006/07" + }, + { + "name": "P 07/08", + "full_name": "Pokal 2007/08" + }, + { + "name": "07/08", + "full_name": "2007/08" + }, + { + "name": "P 08/09", + "full_name": "Pokal 2008/09" + }, + { + "name": "08/09", + "full_name": "2008/09" + }, + { + "name": "09/10", + "full_name": "2009/10" + }, + { + "name": "P 09/10", + "full_name": "Pokal 2009/10" + }, + { + "name": "P 10/11", + "full_name": "Pokal 2010/11" + }, + { + "name": "10/11", + "full_name": "2010/11" + }, + { + "name": "11/12", + "full_name": "2011/12" + }, + { + "name": "P 11/12", + "full_name": "Pokal 2011/12" + }, + { + "name": "P 12/13", + "full_name": "Pokal 2012/13" + }, + { + "name": "12/13", + "full_name": "2012/13" + }, + { + "name": "13/14", + "full_name": "2013/14" + }, + { + "name": "P 13/14", + "full_name": "Pokal 2013/14" + }, + { + "name": "14/15", + "full_name": "2014/15" + }, + { + "name": "P 14/15", + "full_name": "Pokal 2014/15" + }, + { + "name": "15/16", + "full_name": "2015/16" + }, + { + "name": "P 15/16", + "full_name": "Pokal 2015/16" + }, + { + "name": "16/17", + "full_name": "2016/17" + }, + { + "name": "P 16/17", + "full_name": "Pokal 2016/17" + }, + { + "name": "17/18", + "full_name": "2017/18" + }, + { + "name": "P 17/18", + "full_name": "Pokal 2017/18" + }, + { + "name": "P 18/19", + "full_name": "Pokal 2018/19" + }, + { + "name": "18/19", + "full_name": "2018/19" + }, + { + "name": "P 19/20", + "full_name": "Pokal 2019/20" + }, + { + "name": "19/20", + "full_name": "2019/20" + }, + { + "name": "P 20/21", + "full_name": "Pokal 2020/21" + }, + { + "name": "20/21", + "full_name": "2020/21" + }, + { + "name": "21/22", + "full_name": "2021/22" + }, + { + "name": "P 21/22", + "full_name": "Pokal 2021/22" + }, + { + "name": "P 22/23", + "full_name": "Pokal 2022/23" + }, + { + "name": "22/23", + "full_name": "2022/23" + }, + { + "name": "P 23/24", + "full_name": "Pokal 2023/24" + }, + { + "name": "23/24", + "full_name": "2023/24" + }, + { + "name": "P 24/25", + "full_name": "Pokal 2024/25" + }, + { + "name": "24/25", + "full_name": "2024/25" + }, + { + "name": "P 25/26", + "full_name": "Pokal 2025/26" + }, + { + "name": "25/26", + "full_name": "2025/26" + } + ], + "meetings_excerpt": { + "remarks": null, + "meetings": [ + { + "2025-09-29": [ + { + "date": "2025-09-29T16:00:00.000+00:00", + "live": false, + "state": "done", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440510&historicalDataToken=0uRtkSxKI%2BEjfDka6HCGIA%3D%3D", + "league_id": "504417", + "team_away": "Eintracht Frankfurt II (J11)", + "team_home": "TSG Nieder-Erlenbach", + "meeting_id": "15440510", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "5", + "is_confirmed": true, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "5", + "team_away_id": "2995468", + "team_home_id": "2987226", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43009", + "team_home_club_id": "43039", + "is_meeting_complete": true, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-09-30": [ + { + "date": "2025-09-30T16:00:00.000+00:00", + "live": false, + "state": "done", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440488&historicalDataToken=0uRtkSxKI%2BEjfDka6HCGIA%3D%3D", + "league_id": "504417", + "team_away": "Eintracht Frankfurt V", + "team_home": "Harheimer TC (J11)", + "meeting_id": "15440488", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": true, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "10", + "team_away_id": "2995465", + "team_home_id": "2995094", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43009", + "team_home_club_id": "43030", + "is_meeting_complete": true, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-10-02": [ + { + "date": "2025-10-02T16:00:00.000+00:00", + "live": false, + "state": "done", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440487&historicalDataToken=0uRtkSxKI%2BEjfDka6HCGIA%3D%3D", + "league_id": "504417", + "team_away": "TV Niederrad III", + "team_home": "Eintracht Frankfurt II (J11)", + "meeting_id": "15440487", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": true, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "6", + "is_confirmed": true, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "4", + "team_away_id": "2994841", + "team_home_id": "2995468", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": "2025-09-27T11:15:00.000+00:00", + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43037", + "team_home_club_id": "43009", + "is_meeting_complete": true, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-10-05": [ + { + "date": "2025-10-05T08:30:00.000+00:00", + "live": false, + "state": "done", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440494&historicalDataToken=0uRtkSxKI%2BEjfDka6HCGIA%3D%3D", + "league_id": "504417", + "team_away": "Eintracht Frankfurt IV", + "team_home": "TV Niederrad III", + "meeting_id": "15440494", + "round_name": null, + "round_type": "0", + "hall_number": "2", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "1", + "is_confirmed": true, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "9", + "team_away_id": "2995463", + "team_home_id": "2994841", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43009", + "team_home_club_id": "43037", + "is_meeting_complete": true, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-10-24": [ + { + "date": "2025-10-24T16:00:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440489&historicalDataToken=0uRtkSxKI%2BEjfDka6HCGIA%3D%3D", + "league_id": "504417", + "team_away": "TV Niederrad III", + "team_home": "TV Seckbach 1875", + "meeting_id": "15440489", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2994841", + "team_home_id": "2994011", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43037", + "team_home_club_id": "43048", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-10-25": [ + { + "date": "2025-10-25T08:00:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440486&historicalDataToken=0uRtkSxKI%2BEjfDka6HCGIA%3D%3D", + "league_id": "504417", + "team_away": "TSG Nieder-Erlenbach", + "team_home": "Eintracht Frankfurt V", + "meeting_id": "15440486", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2987226", + "team_home_id": "2995465", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43039", + "team_home_club_id": "43009", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + }, + { + "date": "2025-10-25T11:15:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440512&historicalDataToken=0uRtkSxKI%2BEjfDka6HCGIA%3D%3D", + "league_id": "504417", + "team_away": "TSG Oberrad II", + "team_home": "Eintracht Frankfurt II (J11)", + "meeting_id": "15440512", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2985175", + "team_home_id": "2995468", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43041", + "team_home_club_id": "43009", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + }, + { + "date": "2025-10-25T11:15:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440502&historicalDataToken=0uRtkSxKI%2BEjfDka6HCGIA%3D%3D", + "league_id": "504417", + "team_away": "Harheimer TC (J11)", + "team_home": "Eintracht Frankfurt IV", + "meeting_id": "15440502", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2995094", + "team_home_id": "2995463", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43030", + "team_home_club_id": "43009", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-11-11": [ + { + "date": "2025-11-11T17:00:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440500&historicalDataToken=0uRtkSxKI%2BEjfDka6HCGIA%3D%3D", + "league_id": "504417", + "team_away": "TSG Nieder-Erlenbach", + "team_home": "Harheimer TC (J11)", + "meeting_id": "15440500", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2987226", + "team_home_id": "2995094", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43039", + "team_home_club_id": "43030", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-11-12": [ + { + "date": "2025-11-12T16:45:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440501&historicalDataToken=0uRtkSxKI%2BEjfDka6HCGIA%3D%3D", + "league_id": "504417", + "team_away": "Eintracht Frankfurt V", + "team_home": "TSG Oberrad II", + "meeting_id": "15440501", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": true, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2995465", + "team_home_id": "2985175", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": "2025-11-15T09:00:00.000+00:00", + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43009", + "team_home_club_id": "43041", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + } + ], + "round_type": "mixed", + "pdf_version_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=ScheduleReportFOP&group=504417", + "pdf_materials_url": null + } + }, + "association": "HeTTV", + "season": "25/26", + "error": null +} +=== MATCH RESULTS RESPONSE END === +Processing match results for team J11 +Match: Harheimer TC (J11) vs Eintracht Frankfurt V + Date: 2025-09-30T16:00:00.000+00:00 + Status: done (complete) + Result: 0:10 + Meeting ID: 15440488 +Match points from myTischtennis: 0:10 (from team_home perspective) +Searching for existing match with meeting ID: 15440488 +Found match by meeting ID: 22 +Teams in same order: Harheimer TC (J11) = Harheimer TC (J11) +Updated existing match 22 (Meeting 15440488): 0:10 (complete) +Match: Eintracht Frankfurt IV vs Harheimer TC (J11) + Date: 2025-10-25T11:15:00.000+00:00 + Status: scheduled (incomplete) + Result: 0:0 + Meeting ID: 15440502 +Match points from myTischtennis: 0:0 (from team_home perspective) +Searching for existing match with meeting ID: 15440502 +Found match by meeting ID: 31 +Teams in same order: Eintracht Frankfurt IV (J13) = Eintracht Frankfurt IV +Updated existing match 31 (Meeting 15440502): 0:0 (incomplete) +Match: Harheimer TC (J11) vs TSG Nieder-Erlenbach + Date: 2025-11-11T17:00:00.000+00:00 + Status: scheduled (incomplete) + Result: 0:0 + Meeting ID: 15440500 +Match points from myTischtennis: 0:0 (from team_home perspective) +Searching for existing match with meeting ID: 15440500 +Found match by meeting ID: 41 +Teams in same order: Harheimer TC (J11) = Harheimer TC (J11) +Updated existing match 41 (Meeting 15440500): 0:0 (incomplete) +Found 3 matches for team J11 +Processed 3 match results +[nodemon] restarting due to changes... +[nodemon] starting `node server.js` +[nodemon] restarting due to changes... +[nodemon] starting `node server.js` +Starting scheduler service... +Scheduler service started successfully +Rating updates scheduled for 6:00 AM daily (Europe/Berlin timezone) +Match results fetch scheduled for 6:30 AM daily (Europe/Berlin timezone) +Server is running on http://localhost:3000 +Scheduler service started: + - Rating updates: 6:00 AM daily + - Match results fetch: 6:30 AM daily diff --git a/backend/services/autoFetchMatchResultsService.js b/backend/services/autoFetchMatchResultsService.js new file mode 100644 index 0000000..0fc3aa4 --- /dev/null +++ b/backend/services/autoFetchMatchResultsService.js @@ -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(); + diff --git a/backend/services/clubTeamService.js b/backend/services/clubTeamService.js index b851c03..0cb92eb 100644 --- a/backend/services/clubTeamService.js +++ b/backend/services/clubTeamService.js @@ -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; } diff --git a/backend/services/matchService.js b/backend/services/matchService.js index 3ab1f08..b2e0610 100644 --- a/backend/services/matchService.js +++ b/backend/services/matchService.js @@ -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: '' }, diff --git a/backend/services/memberService.js b/backend/services/memberService.js index cc1fe1a..10dab03 100644 --- a/backend/services/memberService.js +++ b/backend/services/memberService.js @@ -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, diff --git a/backend/services/myTischtennisUrlParserService.js b/backend/services/myTischtennisUrlParserService.js new file mode 100644 index 0000000..7e3451c --- /dev/null +++ b/backend/services/myTischtennisUrlParserService.js @@ -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(); diff --git a/backend/services/pdfParserService.js b/backend/services/pdfParserService.js index 6be9e57..44fdeb2 100644 --- a/backend/services/pdfParserService.js +++ b/backend/services/pdfParserService.js @@ -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}"`); + } } } diff --git a/backend/services/schedulerService.js b/backend/services/schedulerService.js index eb0ca0f..59d4c13 100644 --- a/backend/services/schedulerService.js +++ b/backend/services/schedulerService.js @@ -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 */ diff --git a/backend/uploads/team-documents/10_code_list_1760455939125.pdf b/backend/uploads/team-documents/10_code_list_1760455939125.pdf new file mode 100644 index 0000000000000000000000000000000000000000..f8be61f76bcd02c580d884291d0d80ad4208f3b1 GIT binary patch literal 27910 zcmbTd1y~zjw>}C*i)(RrDI}0Uf`{O)1xm4EL4y`8?p}%%cP&zAffg<94yDDlxNFf% zf9E^re1Ey;xzEirPiFR-cdxzH-m})cYbIgSkdb>1`KZuvh!{!#MSL&nU_ zl95wJL;xTF1_A&;fxkOp9)JJ`0Kmb8BO!rf>1gqHWP$&-fRT}3jZqNiai?PGXzhkz z6ns3V^|vi&WAFAjEWe!nqgokDbEJjkUlp!yk0v|dcxLXWS!?JRJPSDV$s)yHP<@}* zub3Z#$-2o3jlyvgPJ5P)#~p+V%<20$Qy(XHJ|z(bhR&3Y1(8NiyQYk+ zrOhp{2`pJ(Z)^8ppTltY*8QN&W8|&-$3?w!lmsUBPbAu=Z&66Tm5G^Hi^@4EaH5@} zn4&%BW|b#Zx^ZS4wHSX4P^}Bui=LqPR13s5h%9q|#-RNZD~LgR7aKie zd;4}@?++!4{_8X9HFaEwODxyI6RYkc64ZnPlS(}^w&NE${LiN^)yZEvy>uH4XA3aAyD9X?b3Zv4)Kp8D+ zKr>dr$YPtzI}VgBZVagtDxTQX`{&`WGU%33sm21x60ioO(6&(ErZ~I-_)h4K-*K;k zi9TU`lEPC!7fV2`4_?ScAq&)U!qy2E8N*J8(Lit;zhgLIyuxZ|7YE?5qcErh>zSf9 zV+|6an=z8egcdSlD3DKt;W4pWU_hnm6ZpxVgu=&(D3pTh;Xb*{rFi9GJ9$=H99v>; z7>!Ty;qkeO2!deB({*5 zN`bBrV*-OJz=G-hJJEODeYWdd796z` zKv_p-&#R6Y(_$dQC-jOZKZtfi@Vi}Vynf=? z8AYa?wq)`+8HwspwQovk`OKvQIzHUdjp4HjEpl1Kc{V#By_d9=5f>~D5%r8}#KI(m zWTRx&#BH1-B`jOEZhGENT0;C%B;+1gnK;aN+TZ=;7-t7E*RXQ2*s%1!<4v+prA@wN z?M`q>ib@q>oMt=Mq2{V&+h@sQdaHAte%6eq+9Mstrp@X4;&%-%??<*>o(vYBS8p`l zXsQ{H81N}pDIJv0H;Wl9%1NlTzG~I;QwuCNEh74;QRSq#!UoSY8&ojW5xAzpjjD~} zi?WG2kAg=HD(xttDtRgGE2$?R52$Q#Zn$hnY+#UMkgF%}b60Z5>pGXKOjAyGmZOy` z+wR!HY<;F1r>v&grj4dQPa96>AQC_o;6yMrh#rv-PD?*dmj@l%w7=|m3495D=}~9q zK)SEL&obM&tFb%u!+8c=K{La&^K++g_sfpT&V;+T+o@ZVo5^n3R_G4h7|qyCIkF-r zcd1m;ObX~E*eAFJyqeS)Ot929zcLlKX_>s9KVR5gqO_rLj)|id2NhHMP#=O11n*zJ zw(StK5lj=*1>y@=g6XU$J}eHs{*5n~l$K>s^$X*3{BQQ(WxuV5A3lxML^L33J}l(6 z27baubDkgd!MNNMf>Aq(B~T0xKm4tr0AwW)Kq6Zs9uWQ z=iQ);vN?hVKac#T9G|)ySHkCn+4Aumd)vRg#!ma?reo)0jf}q!PVeTBylWQ^nB^B@oO zC4zSZk@(vL-65D^LZN()F7s}PNw*Kq`mmortp}7KE zdA^dv(#%ZF{EWqj#g2tS|DvL^LeHM&4b&&jyq{}rNX9@eS$0^?P>vxcJGv&WEFvww zF1|AEE$JqS9z_G$A-TVR9~%>Ao;Yy^WwvLw@=A~EO+!C&a9$|0FiR()OI=7^BB}Yi z>n6BJiYr6AQQPJt!*)9;_RHY}`()*W-GrnCx>e>N+2Fbrft8Kr{$P6ln*oV;!9!*n zXAXMvtv)yF1aoxfqIp^GL~mS@T~A$0JfMdnbu?|FewSHdcg?lU9zH=AXX}F-hXXm+ zBY&EHJbCxTxc$mFy2X8or7ix>53zLpR`;g$SuHM-e49d->@A-a`Oj)_7N93FS9>Qa6`g{3P5?OXpCg$n-3=2n!k*S zv~*vde-kP8{?b!b-14ISjmiE5@x{A$1w!(3JL@}9I}&u!;uoJ5zqNjbRpzZiu05}h zDPjX-(KtSEq-PXnM5VW8u4k|3oQO{^+0V|;GtX&F;4~E1jbssszCDx=XK0#po86h) z-j}uhp7uqM-yi3}X0PJrSF7ugtApgn`&V#pH(5peU5+KbX^WAhcXmOWM22(LXNn$ zCWl;ltcNxS_|r|iTO0k~)+TF+R>a;QgpC z`bZ6Ryqqi<`Bg35%q+~@%>GKW-Cw)?Bh*(_`8P5t^jFEhu|_dT&qtnXXX(cH+S1y_ zQJnd(sg0S@#zLG~53CAMb%I+WY!tj*EVaGWbj-c&%%K*{k`iK`BAyOT4v(WTdOFxU zx{7#;Gn+l~SWA(|6`?+}xZ*`1w6NJor2W`H(Kw z{6Hua$`26W7Z7;+SS`P|W!o8tLvP&ir2r|LU|b7qLRRIGDLfm^nGw+nAgE z)y(f?VI{`@->3e1@gJYRczZKPYj-niO9{{C?v8ejNDs$U3gU(t^%VJY#K`9B8& zo&$h70sLdHK&2J`VoREdHDPU*11j`d{uly7K)q z|9s|1hrcd7x-R%Xxc-O``Ohjw;{RUfi2YUb-=Kf<>>s&|+`nofiN`AHU)cYa{r~jn z@tXR-d*o^6^ym@bfAHwBtH{49HytF>{{Pjf{~h;le*Hc5p8@o@Zu_5>?*G94Z+iYQ z#q$4)mVZP3kF)k4SI1*4N#Ka_|5x}uHvTJqACrkVv&&<2;{2Z(=5N&h%`i|1;6L++ z@c)uO)XW?n^M|4X@X?5WB@=!1*S41CZhr;;Dnudw0Y7_-{+v#?4j3(gluma6&phhAZPg7&y`%>7xCZkt`V>GoPb{ zrRQVf zfIy%CP!I?Lf`LLnVITwu1;T*RKsZnaC<~N(49dq+TtH9&Bmfo=5)c-E2tWm30@4C- z0T}^V0Xac{AW%?1P*4yg2o@9)6c&UCLIq)h(t>b789`Y=IS>E@1POoyK_CzqBm@!$ zK|oLt3?vPLgJeLmAUQAq3Fp=78ZsGLxo|&(!y|I8DUvr zIS2p(ga|+cAs`4CA_NhJKp;>E3?dDILu4Sb5IHCS3WN$k1)(4)7%Bu6hC&`wEDS0Q zg+pbavQRk~00x8!zyx6+7#Jo56NW)xP#6p*4THmEV6redX@E3PT0mM*8YB&t7Lpd0 zhDbxDVbaplaA_H7S!p>q01ku;zy;wTI2bMj7luRNP&f=O4Tr;J;IeQz8GsB>MnFbT z1|$QP5t0#>fyh8*U^3D&a2XjHSs6K5fGki}Kvqx|Bny@mk`FuO>U95j__gTs&A~%k z0Wpyg5fQ+t+8u|{gndmG$`SSYP~z=teszrs&KEMDe4|?O@=a0OgQBFcb;%O#p$}<> zZ-g^iG8R;){o|G{Kd@ddaBd_q<#z;L zSk#poTT%t$&g~l;GWRRWEF4xCxd1#2RiIf^?ifW2JX9di`%nci(=|WRDzXWrQg?%oda|0Td zh?WM6aMIkd6lu7U&svz0JU2n?Iy=Nkf5g+ps$;Mf?va_Jm~98#8inoTXd-+IMN3;W z414=UIJ0KGh1~wJ*nMm%es=L-m(%TM1KkvCKch5@F1{PPy2sr1jb~D=OU&(zSyBV2 zDDY^unS&lF6@QOZJ_)>%G-|HbM31>|6X@sy9?tivu z_c2k8=MMpUMoz&PL69#7`D~wODRE8d-J7+{4OX7m|4pWhn3`l+O4IHMF=5}0y0*nBv>)YKqY+A7U^IyMHcr9pdUa*L~ zf4IJi8qWn92J@<0l5#x6Lq=W)k`MMX$W#MGQD)^ zllQDbG>n4axEWH_pT~z1YY{0$5~mL;_S<~$Ih53RT}NxyoPsywBYasOFw%`Za3vfo z!%pnk@HF@AgiREic3dBeHI|g*!ErK3aA^66BTb5G?6nvCzTgZ?7}98bjdM;g*Z6Ti zQv;O$X3+>$#6*Vv`}zZW2Ubd*;QXx~}CR0|q#<-V|ifgmFT)Xw748MOj$Hhhu~QQ@abdQ!}zzNFgU32U{d#)}xTLBv4KqzgP|1M&NY4Hcu&`LhLaA)|_ zr!ivFMlZ9Bt7p6|*v)@BLC6R;?n8*iq(}ewYk5ucNg`c* z71o3N{0lYL;}DffG_-@5v(%5f$ao(^rFDefneYjH zmKuG!Zj9Y$%O za*f}(=sP9(P@57;5>~Hja}72jh_i|5CRSqBk(DRGeybTxZ9e8h?F3o<&*F}#>PXM? ze)(g_75GP+z7`zd>$(&P%&|OSPB|567d?|i7CQ_m)|KW4-G=HJi?nbAh<>;J5^x&m zRPdGJ=i~+bm@2W3dsD&-qoHGujU} zb~D{_iNmZBxazs={l zhjwgx9?=3^NrnydaG#}*C|EEqy2FiEU~$vI))xZ{wVq1rCVCQd`6dgW){~URZgz(8 z7Vm3pdXWv13=x~&+W^k6s|Dz4qrkBXqm@n2zX#LBU{flNoH=vWLVv1|72oP;TEQ6^zKA(lh8)sCA_vQ9*_s2$C5 zOJvMzd!nyq;>qNdW`DE~F`VZdvZOZGJ&T-!3_QPP9r8vBml45zsfJ>IY57LdTn(06 zv}7UJZbD48JZ}8_u#-5Sp<7Q3{ZiI>At+mwF5+o`qnc0IlZ~ouev=NR2&tYQ!=!mq z_Wk;GB6%Xc$f#6z&zli?S#8?OxS2oHk;+dcD$jRvX8W@9%>gA`%F<8Td@U1AzE?OYPCxuqq@I%myv zu$|6@eiZNTv0}92cbdcuYMux&Yu%+lv)VfbV^7=9zZ^f5W(w=5;A1g;)BS`>ea3)E zyE!@iUFg7z?)ABU2PQwiDs7Kv-z6n-j@>ekowBbmye^o@a>QEDPMOx};w80&!|t@O zcL!Pn!_(PMhfFQ}A#&@xg&pA+`&keIfM_k#4=1|AOZkO7Z-{lVlh27O< z-E3eGt(>N>K--r+v;0;M_mzxDe!0~V2IY(MQNnlU`o$Crn4#QJby!5eA{-1Bbf((? zi|Kk4NeV9gh`Jj^{`V}>8I(W%__OcZIj>COw8U$UEW7ZYJ~i(A&^Ei|D9K&T*Fe>s zf}7q)_j3TJQg$rD+>H$TTZ#dU12bgyDfaeS;FST!SN)*y4Efh| zGiBP=&KmbPUvgaxo4KfVqC#=(4ygZHF1Z9Qi(glkdFf=T(c90NrZtkrsU_zM9JZvG z1q!EWKfN+~c3y#8*luH2zGnra6t6zl80a5Qu+U-M_7DGlok!`>cNlGW;`ar$`;#}; zvj+7jO1;x|A}gCu%Sc?Ts`?wH<%bv7!3N_*UT8gjnGFfj%}e)kN$VW`rS zYrQs*^0OsQrJueS64{6qwKfXT1PdYF$vF(UfW+8tyK1W<=^?W&K_(ebDW3&3cuJ>0 z5Ytii`pL#6Y+m1H(}2D3P^ZsOD==o!;5(`Cq~)!CR~sh>PgJ#S1=9;as^beG=6cdz z7Q!W*e(aF`($2lulrTg{OrGwE)l4(3c9-Fo*Ij*@DPeNi)uaoccOto&qu2VRYT+D5 zs-O9~!UE?w=hnO5ax6&;=EHgp@vvQak?9en=V?4NLE{g}WFdnzu^G8B1EO}LwAR*B zI9Ix5?`QFu_Dg5kGpFNM4DSMpo*bq&PrRt70j++;;y|k#_tJk$F0*6j)J~~ytUrY1 zoN`4xVr{5%W#;2Mvq$tM%*zTp<%9 zFXfGA`j3(FhUxcPG2c@uc}i`b&hMN>^9vs3bc{z3)I|EYphIdR3lx?i1k%1J2YK}_ zK4>uAPajGSH||#Dh)%ChCs@g;NZwygN~_CvISDC9C~G2?SJ<65-Zfm_6-By=sNI#K zAYb3}{``P&%spY9ShX{|;1>zZBh`91=1!GNvk!Vo^=A}!_$qIfrTQQ2tR$#DipoU~;+q&59dHO~FdwRsA^g?+6mN@Y{iJUY6Dhed~ASrr{qT zc-siF9o#k2F2ZEi>8|4NG?|0tPC%&x%Zop98mm`O>~v-ShKO3(F}iPjTN~s+%QIj7 ztKWs_egj|06x~S|j&y#`MX&36!F*)m-rxW6CyYm3P2%U}v-+XJs)mP=PKz2T@F3PO+u#Dn$1P9vT zcw54=pEeU%(LC!#%jTXKN50h5^2KAO%az`0CfQ+6RzIs%cNvZJ9b%NLcT^Y+pY(9< ze<(Ki>JkV-Fo(ppyw7OJm}GpcL~3gyTq{w-!Us92Lp-129|MH2ILv^?ysHpF4hNf0 z2il+bz<+DOHu9^He1xW90;MV zkzfXD=Xr6w%%Pcm@>uj(r!X>5oKikhkJHL((3-NrxV}Dsx(%1!i|yx^!{1h9yqfdV zo!BhtqGsx@dBmL?%cQ!(c0~vU*#Vk703N?DhAtPYQ%$o+($mgK{pH;&pT5q%#SvNk z$#u2foz+sOUf1Qxg}BiA(lne{B1iriTyk%#>*FaM$dN0x+<@t~!0-ZE*YKDidFu3#>4 zZ#93<)HbG=M#;D47JE-+7M0~$ffB~0o%exZ=lASi6(f8GhL~)m7P9mx_HG>t5fR50 zkcNK57|}ErA(y0Pk;M+%UwuUjExRzAJIiYa6DoX|t4{kf zHsf;hbs9zmQR6)-ZD{dU3a3N`q0>Mz71tt>>3JL4 zZp~BG_v1vJe%C-SMgdT^U*-2{MS}j9En?=*w(2-i`wn(Cl2}h5(QnRyj}9=9Q--O! zv0CCP{-VnjLom`|g6esY97Cw^nK`b!dE<*#sXo$*9(E&5CEbEFKst&rCogp`UGi)a zGm-h!G8K~~`e)8=ZnOf^G929rQQ>E)|2@^%^u1Y1_8H(DE*JXW2FF~JJb*_3QG zwUR32y;JtbLm!Na?>oJ(K3DYqi<8&>ub)%%a$ltrg2RFwyB335+P0~^oV!VXj2jo)*0 zq94Q!#hS84Y`pkGx0~ViEX=ckor`)n{idWfB*(zair(&y(>l3KUKJ6~t)b~lUP#BR zs92{vLGxrIwz$TD_V5X`Abs${tkYrm8}1dHB6XhAdURhECqVvKC1Es&GQ9X0a|B~1 zy{DD8ME%#rLHJ>_=NSxsvFm)05pDePi(rm|YG5LF-0r~M0Nd+)ZjY}*bS1+w1qx=A zy*}$Qc$stc6=PDQt>)MYNL(l7$vDb2mnOYd*s!PR^+Eltvy(CRm&H#EPi>;*heb=& zc5#XmNhUw}Y|i_QcDgd$F4eOas_C$FMKdNVhtKLk~ftX3czl$GIH4lf#(QYa>w>#4>Oax8$aHzF)(9D3#*sdaBL$6g(fdoqL zuPnZlbYhe*6sA_*pB>@^&|5b@lspM?`Ca*RUvmZC^)_b3yScr?fgg1NGDn-p9rzi1 z;vRF+Hy}+a7>Q75DvMxB+L4bVuza3imB~s^Nb){$CtK?*Os78;bFkREO%W-vIxY5n z8;MMjZ%eDyux5){eNyT#mXqteYxgo|iwTq^?4+D#`1B^rz-bn7+p)2>QTI+ceJqjE z?~tHx*tFvac3slPsUC{^IbAmiC_(?!X_dMh@{-Arg*^-*5O2^?S#9g9=wA@IR2}s- zX;h65v=@F=HOZewLI04Uiclhp54PZk?#iFHh3rDs$}1RpXAPUjZhBHQiGS_*3=#FA z1lyp;2I)|~NM##`e0Bn!k%j?uXm#T%EW3UBp6v* zunoU=9t9ZoybUKoUQM%-I|ust-nGosZ*JcbDHeI+g(`D*!Rt;^5Qa0AvIVeoZ*Eo} zDrN!xVBytcdbnM!N|P)(KqPb-^gr{r12vWntty7h0O#1TM#}k)HXKN1Y zC}AAAHAL)`*y5~%&vA303FHZmom17oITZtQi#Mh*60)B?1KXZ`ro@>s4K}nqwy=#= zQPdqJ?ntK@{7$~YVpZTMG=SRuN(kMNx01gMr$&q@UP+$i-4~w;+*rRzUu*h8zOvkH zIe}~@^7F&&nlpw@JvX9168k6)t{=2)gmkT#=(|sI+gEN?6q6kjSEAtUiu`(}9htaQ zBj%S(K&P~%!g2>sH>R20h+bO@Ma%vA=Z4-ha?I3Phr~W}i7mLc&p^Jo&g^?C@u=f6 zg2dQwE7#L)3ZqtW(nvj$X9g`3ai~GqQPFrx>sg+?emL^xv21azCuTsm=UV6H{b@az(e8z(JjgmC*qBV@J`SJn~L3jqL5q|zxpkE z>)KXGJc{k4SMRAuNAGY0cG%lilYB!*tUWBYZj;FU{>V^0#jrh%m0G3onf$yT1bw2q z$PD81&;#OG<&8bTp>X=P;27O+;gs@o!7O{T6E(QIvPETDq%)O?HfiTIRs7(_8-pP^ z

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 aEGkzw3m3P;Wdg)ELX4c7TUK3;{Qm&23?upg literal 0 HcmV?d00001 diff --git a/backend/uploads/team-documents/10_pin_list_1760455950179.pdf b/backend/uploads/team-documents/10_pin_list_1760455950179.pdf new file mode 100644 index 0000000000000000000000000000000000000000..07db070bc2a90e3fdbcde53923550f0c85443db9 GIT binary patch literal 6946 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 z

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 aEGkzw3m3P;Wdg)ELX4c7TUK3;{Qm&23?upg literal 0 HcmV?d00001 diff --git a/backend/uploads/team-documents/1_pin_list_1760471862054.pdf b/backend/uploads/team-documents/1_pin_list_1760471862054.pdf new file mode 100644 index 0000000000000000000000000000000000000000..07db070bc2a90e3fdbcde53923550f0c85443db9 GIT binary patch literal 6946 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 Heimmannschaft Gastmannschaft + Ergebnis Altersklasse Code Heim-PIN @@ -47,6 +48,12 @@ {{ match.time ? match.time.toString().slice(0, 5) + ' Uhr' : 'N/A' }} + + + {{ match.homeMatchPoints }}:{{ match.guestMatchPoints }} + + + {{ match.leagueDetails?.name || 'N/A' }} @@ -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; diff --git a/frontend/src/views/TeamManagementView.vue b/frontend/src/views/TeamManagementView.vue index 6c5d788..f91e31d 100644 --- a/frontend/src/views/TeamManagementView.vue +++ b/frontend/src/views/TeamManagementView.vue @@ -57,6 +57,62 @@ + +

+
+

🏓 MyTischtennis Integration

+
+
+ + ✓ Vollständig konfiguriert + + + ⚠ {{ getMyTischtennisStatus(teamToEdit).missing }} + + + ✗ Nicht konfiguriert + +
+ +
+
+
+ +
+ ⏳ Konfiguriere automatisch... +
+
+ + +
+ ⚠️ {{ myTischtennisError }} +
+ + +
+ ✅ {{ myTischtennisSuccess }} +
+
+
@@ -162,6 +218,20 @@ Erstellt: {{ formatDate(team.createdAt) }}
+ + +
+ 🏓 MyTischtennis: + + ✓ Vollständig konfiguriert + + + ⚠ Teilweise konfiguriert + + + ✗ Nicht konfiguriert + +
@@ -210,8 +280,6 @@ - - +