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
+
+
+
+
+
+
{{ parsedData.teamname }}
+
Liga: {{ parsedData.leagueName }}
+
Verband: {{ parsedData.association }}
+
Tabelle: Platz {{ parsedData.tableRank }}
+
+
+
+
+
+
+
+```
+
+## 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
+
+[33m[nodemon] 3.1.4[39m
+[33m[nodemon] to restart at any time, enter `rs`[39m
+[33m[nodemon] watching path(s): *.*[39m
+[33m[nodemon] watching extensions: js,mjs,cjs,json[39m
+[32m[nodemon] starting `node server.js`[39m
+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
+[32m[nodemon] restarting due to changes...[39m
+[32m[nodemon] starting `node server.js`[39m
+[32m[nodemon] restarting due to changes...[39m
+[32m[nodemon] starting `node server.js`[39m
+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 0000000..f8be61f
Binary files /dev/null and b/backend/uploads/team-documents/10_code_list_1760455939125.pdf differ
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 0000000..07db070
Binary files /dev/null and b/backend/uploads/team-documents/10_pin_list_1760455950179.pdf differ
diff --git a/backend/uploads/team-documents/1_code_list_1760470543608.pdf b/backend/uploads/team-documents/1_code_list_1760470543608.pdf
new file mode 100644
index 0000000..f8be61f
Binary files /dev/null and b/backend/uploads/team-documents/1_code_list_1760470543608.pdf differ
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 0000000..07db070
Binary files /dev/null and b/backend/uploads/team-documents/1_pin_list_1760471862054.pdf differ
diff --git a/frontend/src/views/ScheduleView.vue b/frontend/src/views/ScheduleView.vue
index a4f77a0..9ad872a 100644
--- a/frontend/src/views/ScheduleView.vue
+++ b/frontend/src/views/ScheduleView.vue
@@ -34,6 +34,7 @@
Uhrzeit |
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 @@
+
+
+
+
+
+
+
+ ⚠️ {{ myTischtennisError }}
+
+
+
+
+ ✅ {{ myTischtennisSuccess }}
+
+
+
@@ -162,6 +218,20 @@
Erstellt:
{{ formatDate(team.createdAt) }}
+
+
+
+ 🏓 MyTischtennis:
+
+ ✓ Vollständig konfiguriert
+
+
+ ⚠ Teilweise konfiguriert
+
+
+ ✗ Nicht konfiguriert
+
+
@@ -210,8 +280,6 @@
-
-
+
|