diff --git a/skills/django-templates/SKILL.md b/skills/django-templates/SKILL.md
new file mode 100644
index 0000000..6763242
--- /dev/null
+++ b/skills/django-templates/SKILL.md
@@ -0,0 +1,507 @@
+---
+name: django-templates
+description: Creates Django 6.0 HTML templates with partials, HTMX integration, and modern best practices. Use for template creation, refactoring, or HTMX endpoints.
+argument-hint: [template-name] [--partial|--htmx|--base]
+allowed-tools: Read, Write, Edit, Glob, Grep
+---
+
+# Django 6.0 HTML Templates
+
+Generate production-ready Django 6.0.2 templates with Template Partials, HTMX integration, CSP support, and modern best practices for the league-planner project.
+
+## When to Use
+
+- Creating new HTML templates with Django 6.0 features
+- Refactoring templates to use Template Partials
+- Building HTMX-powered dynamic components
+- Setting up base templates with proper block structure
+- Implementing reusable template fragments
+
+## Prerequisites
+
+- Django 6.0+ installed
+- Templates directory configured in settings
+- For HTMX: `django-htmx` package (optional but recommended)
+
+## Instructions
+
+### Step 1: Analyze Request
+
+Parse `$ARGUMENTS` to determine:
+- **Template name**: The target template file
+- **Type flag**:
+ - `--partial`: Create reusable partial fragments
+ - `--htmx`: HTMX-enabled template with partial endpoints
+ - `--base`: Base template with block structure
+ - (none): Standard template
+
+### Step 2: Check Existing Templates
+
+```bash
+# Find existing templates in league-planner
+find . -path "*/templates/*.html" -type f
+```
+
+Review the project's template structure and naming conventions.
+
+### Step 3: Generate Template
+
+Apply the appropriate pattern from the examples below.
+
+---
+
+## Django 6.0 Template Features
+
+### Template Partials (NEW in 6.0)
+
+Define reusable fragments without separate files:
+
+```django
+{# Define a partial #}
+{% partialdef card %}
+
+
{{ title }}
+
{{ content }}
+
+{% endpartialdef %}
+
+{# Render the partial multiple times #}
+{% partial card %}
+{% partial card %}
+```
+
+**Inline Option** - Render immediately AND save for reuse:
+
+```django
+{% partialdef filter_controls inline %}
+
+{% endpartialdef %}
+
+{# Can still reuse later #}
+{% partial filter_controls %}
+```
+
+### Accessing Partials from Views (HTMX Pattern)
+
+Render only a specific partial:
+
+```python
+# views.py
+from django.shortcuts import render
+
+def update_component(request, pk):
+ obj = MyModel.objects.get(pk=pk)
+ # Render ONLY the partial named "item_row"
+ return render(request, "myapp/list.html#item_row", {"item": obj})
+```
+
+### forloop.length (NEW in 6.0)
+
+Access total loop count:
+
+```django
+{% for item in items %}
+
+ {{ item.name }}
+ ({{ forloop.counter }}/{{ forloop.length }})
+
+{% endfor %}
+```
+
+### querystring Tag Improvements
+
+Build query strings cleanly:
+
+```django
+{# Basic usage #}
+First Page
+
+{# Modify current query params #}
+Next
+
+{# Remove a parameter #}
+Clear Filter
+
+{# Multiple mappings (NEW in 6.0) #}
+Reset
+```
+
+### CSP Nonce Support
+
+For inline scripts with Content Security Policy:
+
+```django
+{# In settings: add 'django.template.context_processors.csp' #}
+
+
+```
+
+---
+
+## Patterns & Best Practices
+
+### Pattern 1: Base Template with Blocks
+
+```django
+{# templates/base.html #}
+{% load static %}
+
+
+
+
+
+ {% block title %}League Planner{% endblock %}
+
+ {% block extra_css %}{% endblock %}
+
+
+ {% block navbar %}
+ {% include "includes/navbar.html" %}
+ {% endblock %}
+
+
+ {% block messages %}
+ {% if messages %}
+ {% for message in messages %}
+ {{ message }}
+ {% endfor %}
+ {% endif %}
+ {% endblock %}
+
+ {% block content %}{% endblock %}
+
+
+ {% block footer %}
+ {% include "includes/footer.html" %}
+ {% endblock %}
+
+ {% block extra_js %}{% endblock %}
+
+
+```
+
+### Pattern 2: List Template with Partials
+
+```django
+{# templates/scheduler/scenario_list.html #}
+{% extends "base.html" %}
+
+{% block title %}Szenarien{% endblock %}
+
+{% block content %}
+
+
Szenarien
+
+ {# Define row partial for HTMX updates #}
+ {% partialdef scenario_row %}
+
+ | {{ scenario.name }} |
+ {{ scenario.season }} |
+ {{ scenario.get_status_display }} |
+
+ Details
+
+ |
+
+ {% endpartialdef %}
+
+
+
+
+ | Name |
+ Saison |
+ Status |
+ Aktionen |
+
+
+
+ {% for scenario in scenarios %}
+ {% partial scenario_row %}
+ {% empty %}
+ | Keine Szenarien vorhanden. |
+ {% endfor %}
+
+
+
+{% endblock %}
+```
+
+### Pattern 3: HTMX-Powered Form
+
+```django
+{# templates/scheduler/scenario_form.html #}
+{% extends "base.html" %}
+
+{% block content %}
+
+{% endblock %}
+```
+
+### Pattern 4: Detail Page with Inline Editing
+
+```django
+{# templates/scheduler/scenario_detail.html #}
+{% extends "base.html" %}
+
+{% block content %}
+
+ {# Header partial - editable via HTMX #}
+ {% partialdef scenario_header inline %}
+
+ {% endpartialdef %}
+
+ {# Games list partial #}
+ {% partialdef games_list %}
+
+ Spiele ({{ games|length }}/{{ games|length }})
+
+ {% for game in games %}
+ -
+ {{ game.home_team }} vs {{ game.away_team }}
+ {{ game.date|date:"d.m.Y" }}
+
+ {% endfor %}
+
+
+ {% endpartialdef %}
+
+ {% partial games_list %}
+
+{% endblock %}
+```
+
+### Pattern 5: Modal with Partial
+
+```django
+{# templates/includes/modal.html #}
+{% partialdef modal_container %}
+
+ {% if show %}
+
+
+ {% block modal_header %}
+
+ {% endblock %}
+
+ {% block modal_body %}
+ {{ modal_content }}
+ {% endblock %}
+
+ {% block modal_footer %}
+
+ {% endblock %}
+
+ {% endif %}
+
+{% endpartialdef %}
+```
+
+### Pattern 6: Pagination with querystring
+
+```django
+{# templates/includes/pagination.html #}
+{% if page_obj.has_other_pages %}
+
+{% endif %}
+```
+
+---
+
+## HTMX View Pattern
+
+For HTMX endpoints that render partials:
+
+```python
+# views.py
+from django.shortcuts import render, get_object_or_404
+from django.views.decorators.http import require_http_methods
+
+@require_http_methods(["GET"])
+def scenario_row(request, pk):
+ """Render single scenario row partial for HTMX."""
+ scenario = get_object_or_404(Scenario, pk=pk)
+ # Syntax: "template.html#partial_name"
+ return render(request, "scheduler/scenario_list.html#scenario_row", {
+ "scenario": scenario
+ })
+
+@require_http_methods(["DELETE"])
+def scenario_delete(request, pk):
+ """Delete scenario and return empty response for HTMX swap."""
+ scenario = get_object_or_404(Scenario, pk=pk)
+ scenario.delete()
+ # Return empty string - HTMX will remove the row
+ return HttpResponse("")
+
+@require_http_methods(["GET", "POST"])
+def scenario_edit_header(request, pk):
+ """Inline edit scenario header via HTMX."""
+ scenario = get_object_or_404(Scenario, pk=pk)
+
+ if request.method == "POST":
+ form = ScenarioHeaderForm(request.POST, instance=scenario)
+ if form.is_valid():
+ form.save()
+ # Return the display partial
+ return render(request, "scheduler/scenario_detail.html#scenario_header", {
+ "scenario": scenario
+ })
+ else:
+ form = ScenarioHeaderForm(instance=scenario)
+
+ # Return edit form partial
+ return render(request, "scheduler/scenario_edit_header.html", {
+ "scenario": scenario,
+ "form": form
+ })
+```
+
+---
+
+## Template Organization (league-planner)
+
+```
+templates/
+├── base.html # Main base template
+├── includes/
+│ ├── navbar.html
+│ ├── footer.html
+│ ├── pagination.html
+│ └── modal.html
+├── scheduler/
+│ ├── scenario_list.html # With partials for rows
+│ ├── scenario_detail.html # With partials for sections
+│ ├── scenario_form.html # With form partial
+│ └── _scenario_row.html # Standalone partial (legacy)
+├── draws/
+│ └── ...
+└── qualifiers/
+ └── ...
+```
+
+---
+
+## Common Pitfalls
+
+- **Partial nicht gefunden**: Partial-Namen müssen exakt matchen, keine Leerzeichen
+- **Context fehlt**: Partials erben den Context - stelle sicher, dass Variablen verfügbar sind
+- **HTMX Swap-Probleme**: Verwende IDs für präzises Targeting (`hx-target="#element-id"`)
+- **CSP Violations**: Inline-Styles/Scripts brauchen `nonce="{{ csp_nonce }}"` wenn CSP aktiv
+- **N+1 in Templates**: Verwende `select_related`/`prefetch_related` in Views, nicht im Template
+- **forloop.length Performance**: Bei großen Listen kann dies teuer sein (zählt vorab)
+
+## Deprecation Warning
+
+⚠️ **urlize Filter**: Default-Protokoll wechselt von HTTP zu HTTPS in Django 7.0.
+Setze `URLIZE_ASSUME_HTTPS = True` in settings.py für Vorwärtskompatibilität.
+
+---
+
+## Verification
+
+Test the template:
+
+```bash
+# Check for template syntax errors
+python manage.py check --deploy
+
+# Render template in shell
+python manage.py shell
+>>> from django.template.loader import render_to_string
+>>> html = render_to_string('scheduler/scenario_list.html', {'scenarios': []})
+>>> print(html)
+
+# Test partial rendering
+>>> html = render_to_string('scheduler/scenario_list.html#scenario_row', {'scenario': scenario})
+```
diff --git a/skills/qualifiers-model/SKILL.md b/skills/qualifiers-model/SKILL.md
new file mode 100644
index 0000000..47825ac
--- /dev/null
+++ b/skills/qualifiers-model/SKILL.md
@@ -0,0 +1,123 @@
+# qualifiers-model
+
+Erstellt und erweitert Django Models für das qualifiers-Modul (UEFA Qualifikationsturniere).
+
+## Trigger
+
+- Neue QNode, QMatch, QGame, QGrouping Models
+- Model-Erweiterungen für Qualifiers
+- "qualifiers model", "qnode", "qmatch", "qgame"
+
+## Kontext
+
+Das qualifiers-Modul verwaltet UEFA-Qualifikationsturniere mit folgender Hierarchie:
+- **QPath** → QTier → QNode (Turnier-Struktur)
+- **QNode** → QGrouping → QPosition (Gruppenbildung)
+- **QNode** → QMatch → QGame (Spielpaarungen)
+
+## Regeln
+
+1. **Immer db_index für ForeignKeys setzen** wenn häufig gefiltert wird:
+ ```python
+ scenario = models.ForeignKey('scheduler.Scenario', on_delete=models.CASCADE, db_index=True)
+ ```
+
+2. **Meta-Klasse mit indexes und constraints**:
+ ```python
+ class Meta:
+ ordering = ['-tier__ypos', '-stage__xpos']
+ constraints = [
+ models.UniqueConstraint(fields=['name', 'scenario'], name='unique_qnode_name_scenario')
+ ]
+ indexes = [
+ models.Index(fields=['scenario', 'tier', 'stage'], name='qnode_scenario_tier_stage_idx'),
+ ]
+ ```
+
+3. **related_name konsistent benennen**:
+ - Plural für ForeignKey: `related_name='groupings'`
+ - Model-Name für M2M: `related_name='qnodes_seeded'`
+
+4. **State-Machine Pattern für QNode**:
+ ```python
+ NODESTATE_CHOICES = (
+ (0, "none"), # Warten auf Vorgänger
+ (1, "seeded"), # Teams zugeordnet
+ (2, "grouping"), # Gruppen gebildet
+ (3, "draw"), # Spiele ausgelost
+ (4, "finalized"), # Alle Ergebnisse da
+ )
+ ```
+
+5. **GameMixin für QGame verwenden**:
+ ```python
+ from scheduler.models import GameMixin
+
+ class QGame(GameMixin):
+ # Erbt: homeGoals, awayGoals, resultEntered, season
+ qnode = models.ForeignKey(QNode, ...)
+ ```
+
+6. **Signals mit transaction.atomic()**:
+ ```python
+ @receiver(post_save, sender=QGame)
+ def post_match_signal(sender, instance, created, **kwargs):
+ with transaction.atomic():
+ # Signal-Logik
+ ```
+
+## Beispiel: Neues Feld zu QNode
+
+```python
+# qualifiers/models.py
+class QNode(models.Model):
+ # Existierende Felder...
+
+ # Neues Feld
+ auto_advance = models.BooleanField(
+ default=False,
+ help_text="Automatisch zum nächsten State wechseln"
+ )
+```
+
+## Beispiel: Neues Model
+
+```python
+# qualifiers/models.py
+class QNodeViolation(models.Model):
+ """Speichert Constraint-Verletzungen für einen Node."""
+ node = models.ForeignKey(
+ QNode,
+ on_delete=models.CASCADE,
+ related_name='violations',
+ db_index=True
+ )
+ scenario = models.ForeignKey(
+ 'scheduler.Scenario',
+ on_delete=models.CASCADE,
+ db_index=True
+ )
+ type = models.CharField(max_length=50) # 'country_clash', 'distance', etc.
+ message = models.TextField()
+ severity = models.IntegerField(default=1) # 1=Warning, 2=Error
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ ordering = ['-created_at']
+ indexes = [
+ models.Index(fields=['node', 'scenario'], name='qnodeviolation_node_scen_idx'),
+ ]
+```
+
+## Migration erstellen
+
+```bash
+conda activate planner
+python manage.py makemigrations qualifiers
+python manage.py migrate qualifiers
+```
+
+## Referenz-Dateien
+
+- [qualifiers/models.py](qualifiers/models.py) - Alle Models
+- [docs/qualifiers/models/](docs/qualifiers/models/) - Dokumentation
diff --git a/skills/qualifiers-seeding/SKILL.md b/skills/qualifiers-seeding/SKILL.md
new file mode 100644
index 0000000..cfd929a
--- /dev/null
+++ b/skills/qualifiers-seeding/SKILL.md
@@ -0,0 +1,163 @@
+# qualifiers-seeding
+
+Automatisiert das Team-Seeding für UEFA-Qualifikationsturniere.
+
+## Trigger
+
+- Neue Saison erstellen
+- Team-Import
+- "seeding", "seed teams", "neue saison"
+
+## Kontext
+
+Das Seeding erstellt die Node-Struktur und weist Teams basierend auf UEFA-Koeffizienten zu. Die Hauptfunktion ist `seed_nodes25()` in [qualifiers/helpers.py](qualifiers/helpers.py).
+
+## Regeln
+
+1. **Node-Struktur folgt UEFA-Format**:
+ ```
+ Champions Path:
+ └── UCL: Q1 → Q2 → Q3 → PO
+ └── UEL: Q2 → Q3 → PO
+ └── UECL: Q3 → PO
+
+ League Path:
+ └── UCL: Q2 → Q3 → PO
+ └── UEL: Q3 → PO
+ ```
+
+2. **Koeffizienten-Ranking**:
+ ```python
+ # Formel: Koeffizient * 100000 - Position
+ # Höherer Wert = besseres Ranking
+ coefficients[team.id] = float(100000 * team.coefficient - team.position)
+ ```
+
+3. **State-Transitions**:
+ - `none` (0) → `seeded` (1): Nach `seeded_teams.set()`
+ - Vorgänger-Nodes müssen mindestens State `draw` (3) haben
+
+4. **GroupsOfSize automatisch erstellen**:
+ ```python
+ node.create_groupsofsize()
+ # Erstellt: 4, 6, 8, 10, 12, 20 mit number=0
+ ```
+
+## Beispiel: Nodes für neue Saison erstellen
+
+```python
+from qualifiers.models import QPath, QTier, QStage, QNode
+
+# Path erstellen
+cp = QPath.objects.create(scenario=scenario, name='Champions Path')
+
+# Tiers erstellen
+ucl = QTier.objects.create(
+ scenario=scenario,
+ path=cp,
+ name='UCL',
+ ypos=3,
+ optimize_prio=50
+)
+
+# Stages erstellen
+q1 = QStage.objects.create(scenario=scenario, path=cp, name='Q1', xpos=1)
+q2 = QStage.objects.create(scenario=scenario, path=cp, name='Q2', xpos=2)
+
+# Nodes erstellen mit Navigation
+node_q1 = QNode.objects.create(
+ scenario=scenario,
+ name='Q1',
+ tier=ucl,
+ stage=q1,
+ type=0, # Grouping
+ state=1, # Seeded
+)
+
+node_q2 = QNode.objects.create(
+ scenario=scenario,
+ name='Q2',
+ tier=ucl,
+ stage=q2,
+ type=0,
+ state=0, # Wartet auf Q1
+)
+
+# Navigation setzen
+node_q1.winners = node_q2
+node_q1.save()
+```
+
+## Beispiel: Teams seeden
+
+```python
+from scheduler.models import Team
+from qualifiers.models import QNode
+
+node = QNode.objects.get(
+ scenario=scenario,
+ tier__name='UCL',
+ stage__name='Q1'
+)
+
+# Top 16 Teams nach Koeffizient
+teams = Team.objects.filter(
+ season=scenario.season,
+ active=True
+).order_by('-coefficient')[:16]
+
+# Teams zuweisen
+node.seeded_teams.set(teams)
+node.state = 1 # Seeded
+node.save()
+
+# Gruppengrößen erstellen
+node.create_groupsofsize()
+```
+
+## Beispiel: Upcomers von Vorgänger-Node
+
+```python
+# Nach Q1 Draw: Teams für Q2 sammeln
+q1_node = QNode.objects.get(scenario=scenario, tier__name='UCL', stage__name='Q1')
+q2_node = QNode.objects.get(scenario=scenario, tier__name='UCL', stage__name='Q2')
+
+# Upcomers sind finalisierte Matches aus Q1
+upcomers = q2_node.upcomers(scenario)
+
+# Format:
+# [
+# {'id': 123, 'team': , 'coeff': 45.5, 'countries': ['GER'], ...},
+# {'id': 456, 'match': , 'coeff': 30.2, 'countries': ['ESP', 'ITA'], ...},
+# ]
+```
+
+## Validierung
+
+```python
+def validate_node_seeding(node):
+ """Prüft Seeding-Voraussetzungen."""
+ errors = []
+
+ # Mindestens 2 Teams
+ total_teams = node.nTeams(node.upcomers(node.scenario))
+ if total_teams < 2:
+ errors.append("Mindestens 2 Teams erforderlich")
+
+ # Gerade Anzahl
+ if total_teams % 2 != 0:
+ errors.append("Ungerade Teamanzahl")
+
+ # Alle Vorgänger finalisiert
+ for pred in node.qnode_winners.all() | node.qnode_losers.all():
+ if pred.current_state(node.scenario) < 3:
+ errors.append(f"Vorgänger {pred.which()} nicht im Draw-State")
+
+ return errors
+```
+
+## Referenz-Dateien
+
+- [qualifiers/helpers.py](qualifiers/helpers.py) - seed_nodes25()
+- [qualifiers/uefadigitalapi/seed_2025.py](qualifiers/uefadigitalapi/seed_2025.py)
+- [docs/qualifiers/workflows/seeding.md](docs/qualifiers/workflows/seeding.md)
diff --git a/skills/qualifiers-solver/SKILL.md b/skills/qualifiers-solver/SKILL.md
new file mode 100644
index 0000000..06b0a24
--- /dev/null
+++ b/skills/qualifiers-solver/SKILL.md
@@ -0,0 +1,138 @@
+# qualifiers-solver
+
+Erstellt und optimiert MIP-Modelle für die Gruppenbildung in UEFA-Qualifikationsturnieren.
+
+## Trigger
+
+- Neue Optimierungsregeln
+- Solver-Constraints
+- "grouping solver", "mip constraint", "optimization"
+
+## Kontext
+
+Der Solver verwendet PuLP mit Xpress/CBC Backend zur Gruppenbildung. Hauptfunktion ist `groupTeams()` in [qualifiers/solver/draws.py](qualifiers/solver/draws.py).
+
+## Regeln
+
+1. **Solver-Auswahl über Environment**:
+ ```python
+ from leagues.settings import SOLVER
+
+ if SOLVER == 'xpress':
+ solver = XPRESS_PY(msg=1, mipgap=mipgap)
+ else:
+ solver = PULP_CBC_CMD(msg=1, mipGap=mipgap)
+ ```
+
+2. **Entscheidungsvariablen als Dict**:
+ ```python
+ # Team t in Gruppe g
+ x = pulp.LpVariable.dicts(
+ "x",
+ [(t, g) for t in teams for g in groups],
+ cat='Binary'
+ )
+ ```
+
+3. **Constraints mit lpSum**:
+ ```python
+ from pulp import lpSum
+
+ # Jedes Team genau einer Gruppe zuordnen
+ for t in teams:
+ model += lpSum(x[t, g] for g in groups) == 1
+ ```
+
+4. **Zielfunktion mit Gewichtung**:
+ ```python
+ # priority: 0-100 (Distanz vs. Koeffizient)
+ coeff_weight = priority / 100
+ dist_weight = 1 - coeff_weight
+
+ model += coeff_weight * coeff_deviation + dist_weight * total_distance
+ ```
+
+5. **Infeasibility-Handling**:
+ ```python
+ status = model.solve(solver)
+ if status != pulp.LpStatusOptimal:
+ # IIS (Irreducible Infeasible Set) analysieren
+ raise InfeasibleError(f"Solver status: {pulp.LpStatus[status]}")
+ ```
+
+## Beispiel: Neuer Constraint
+
+```python
+# qualifiers/solver/draws.py - in groupTeams()
+
+# Beispiel: Maximal 2 Teams aus Top-10 Ländern pro Gruppe
+top10_countries = ['ESP', 'ENG', 'GER', 'ITA', 'FRA', 'POR', 'NED', 'BEL', 'UKR', 'TUR']
+
+for g in groups:
+ model += lpSum(
+ x[t, g]
+ for t in teams
+ if any(c in top10_countries for c in t_countries[t])
+ ) <= 2, f"max_top10_in_group_{g}"
+```
+
+## Beispiel: Soft Constraint (Zielfunktion)
+
+```python
+# Strafe für ungleiche Gruppengrößen
+size_penalty = pulp.LpVariable.dicts(
+ "size_penalty",
+ groups,
+ lowBound=0,
+ cat='Continuous'
+)
+
+avg_size = sum(groupsizes_all.values()) / len(groups)
+for g in groups:
+ model += size_penalty[g] >= lpSum(x[t, g] for t in teams) - avg_size
+ model += size_penalty[g] >= avg_size - lpSum(x[t, g] for t in teams)
+
+# Zur Zielfunktion addieren
+model += ... + 0.1 * lpSum(size_penalty[g] for g in groups)
+```
+
+## Debugging
+
+```python
+# Verbose Output
+model.solve(XPRESS_PY(msg=1))
+
+# Variablenwerte ausgeben
+for v in model.variables():
+ if v.varValue > 0.5:
+ print(f"{v.name} = {v.varValue}")
+
+# Constraint-Slack prüfen
+for name, constraint in model.constraints.items():
+ if constraint.slack < 0:
+ print(f"Verletzt: {name}, slack={constraint.slack}")
+```
+
+## Performance-Tipps
+
+1. **Symmetrie-Breaking**: Erste Gruppe fixieren
+ ```python
+ # Erstes Team immer in Gruppe 0
+ model += x[teams[0], 0] == 1
+ ```
+
+2. **MIP-Gap setzen**:
+ ```python
+ solver = XPRESS_PY(mipgap=0.01) # 1% Toleranz
+ ```
+
+3. **Warm-Start mit fixed_groups**:
+ ```python
+ for (team_id, group_id) in fixed_groups:
+ model += x[team_id, group_id] == 1
+ ```
+
+## Referenz-Dateien
+
+- [qualifiers/solver/draws.py](qualifiers/solver/draws.py) - Solver-Code
+- [docs/qualifiers/solver/](docs/qualifiers/solver/) - Dokumentation
diff --git a/skills/qualifiers-uefa-api/SKILL.md b/skills/qualifiers-uefa-api/SKILL.md
new file mode 100644
index 0000000..f29df4d
--- /dev/null
+++ b/skills/qualifiers-uefa-api/SKILL.md
@@ -0,0 +1,184 @@
+# qualifiers-uefa-api
+
+Integriert die UEFA Digital API für Team-Daten, Koeffizienten und Clashes.
+
+## Trigger
+
+- UEFA API-Aufrufe
+- Team-Synchronisation
+- Clash-Updates
+- "uefa api", "team sync", "clashes aktualisieren"
+
+## Kontext
+
+Die UEFA Digital API verwendet OAuth2 Client Credentials Flow. API-Skripte befinden sich in [qualifiers/uefadigitalapi/](qualifiers/uefadigitalapi/).
+
+## Regeln
+
+1. **Umgebungsvariablen für Credentials**:
+ ```bash
+ UEFA_OAUTH_TENANT_ID=
+ UEFA_OAUTH_CLIENT_ID=
+ UEFA_OAUTH_CLIENT_SECRET=
+ UEFA_OAUTH_SCOPE=
+ UEFA_SUBSCRIPTION_KEY=
+ UEFA_SUBSCRIPTION_KEY_TOKEN=
+ ```
+
+2. **Token-Abruf**:
+ ```python
+ import requests
+ import os
+
+ def get_uefa_token():
+ tenant_id = os.getenv("UEFA_OAUTH_TENANT_ID")
+ response = requests.post(
+ f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token",
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ data={
+ "grant_type": "client_credentials",
+ "client_id": os.getenv("UEFA_OAUTH_CLIENT_ID"),
+ "client_secret": os.getenv("UEFA_OAUTH_CLIENT_SECRET"),
+ "scope": os.getenv("UEFA_OAUTH_SCOPE"),
+ },
+ )
+ return response.json()['access_token']
+ ```
+
+3. **API-Aufruf mit Bearer Token**:
+ ```python
+ token = get_uefa_token()
+ headers = {
+ "Authorization": f"Bearer {token}",
+ "Ocp-Apim-Subscription-Key": os.getenv("UEFA_SUBSCRIPTION_KEY_TOKEN"),
+ }
+
+ response = requests.get(
+ "https://api.digital.uefa.com/comp/v2/...",
+ headers=headers,
+ timeout=30
+ )
+ ```
+
+4. **Retry-Logik bei Fehlern**:
+ ```python
+ import time
+
+ def safe_api_call(url, headers, retries=3):
+ for attempt in range(retries):
+ try:
+ response = requests.get(url, headers=headers, timeout=30)
+ if response.status_code == 429: # Rate limit
+ retry_after = int(response.headers.get('Retry-After', 60))
+ time.sleep(retry_after)
+ continue
+ response.raise_for_status()
+ return response.json()
+ except requests.exceptions.RequestException as e:
+ if attempt == retries - 1:
+ raise
+ time.sleep(2 ** attempt) # Exponential backoff
+ ```
+
+5. **Daten-Mapping**:
+ | UEFA Feld | Django Model | Feld |
+ |-----------|--------------|------|
+ | `teamId` | Team | `external_id` |
+ | `teamName` | Team | `name` |
+ | `countryCode` | Team.countryObj | `shortname` |
+ | `coefficient` | Team | `coefficient` |
+ | `position` | Team | `position` |
+
+## Beispiel: Teams importieren
+
+```python
+import requests
+from scheduler.models import Team, Country
+
+def import_teams(scenario, competition_id):
+ token = get_uefa_token()
+ headers = {
+ "Authorization": f"Bearer {token}",
+ "Ocp-Apim-Subscription-Key": os.getenv("UEFA_SUBSCRIPTION_KEY_TOKEN"),
+ }
+
+ url = f"https://api.digital.uefa.com/comp/v2/competitions/{competition_id}/teams"
+ data = safe_api_call(url, headers)
+
+ for team_data in data['teams']:
+ country = Country.objects.get(shortname=team_data['countryCode'])
+
+ team, created = Team.objects.update_or_create(
+ season=scenario.season,
+ external_id=team_data['teamId'],
+ defaults={
+ 'name': team_data['teamName'],
+ 'countryObj': country,
+ 'coefficient': team_data.get('coefficient', 0),
+ 'position': team_data.get('position', 0),
+ 'active': True,
+ }
+ )
+
+ if created:
+ print(f"Neues Team: {team.name}")
+```
+
+## Beispiel: Clashes aktualisieren
+
+```python
+from qualifiers.models import QClash
+
+def update_clashes(scenario, season_code):
+ token = get_uefa_token()
+ headers = {...}
+
+ url = f"https://api.digital.uefa.com/comp/v2/seasons/{season_code}/clashes"
+ data = safe_api_call(url, headers)
+
+ for clash_data in data['clashes']:
+ team1 = Team.objects.get(
+ season=scenario.season,
+ external_id=clash_data['team1Id']
+ )
+ team2 = Team.objects.get(
+ season=scenario.season,
+ external_id=clash_data['team2Id']
+ )
+
+ QClash.objects.update_or_create(
+ scenario=scenario,
+ team1=team1,
+ team2=team2,
+ defaults={
+ 'type': clash_data.get('type', 0),
+ 'active_q1': True,
+ 'active_q2': True,
+ 'active_q3': True,
+ 'active_po': True,
+ }
+ )
+```
+
+## Caching (geplant)
+
+```python
+from django.core.cache import cache
+
+def get_cached_api_data(key, url, headers, timeout=3600):
+ """Cached API-Aufruf."""
+ cached = cache.get(key)
+ if cached:
+ return cached
+
+ data = safe_api_call(url, headers)
+ cache.set(key, data, timeout)
+ return data
+```
+
+## Referenz-Dateien
+
+- [qualifiers/uefadigitalapi/uefa_api_2526.py](qualifiers/uefadigitalapi/uefa_api_2526.py)
+- [qualifiers/uefadigitalapi/seed_2025.py](qualifiers/uefadigitalapi/seed_2025.py)
+- [qualifiers/uefadigitalapi/update_clashes.py](qualifiers/uefadigitalapi/update_clashes.py)
+- [docs/qualifiers/api/uefa-oauth2.md](docs/qualifiers/api/uefa-oauth2.md)
diff --git a/skills/translation-review/SKILL.md b/skills/translation-review/SKILL.md
new file mode 100644
index 0000000..129b4b9
--- /dev/null
+++ b/skills/translation-review/SKILL.md
@@ -0,0 +1,117 @@
+---
+name: translation-review
+description: Überprüft Django .po Übersetzungsdateien auf Qualität, Kontext-Passgenauigkeit, Konsistenz und fehlende Übersetzungen. Verbessert Übersetzungen direkt. Für Translation-QA und Review.
+argument-hint: "[locale] [--dry-run]"
+allowed-tools: Glob, Grep, Read, Edit, Write
+user-invocable: true
+---
+
+# Django Translation Review
+
+Überprüft und verbessert Django `.po` Übersetzungsdateien im league-planner Projekt. Stellt sicher, dass Übersetzungen kontextgerecht, konsistent und vollständig sind.
+
+## Argumente
+
+- `locale` (optional): Bestimmte Sprache prüfen, z.B. `de`, `fr`, `ko`. Ohne Angabe → alle Locales.
+- `--dry-run` (optional): Nur Bericht erstellen, keine Änderungen vornehmen.
+
+## Workflow
+
+### Phase 1: Discovery
+
+1. Finde alle `.po` Files mit Glob: `**/locale/*/LC_MESSAGES/django.po`
+2. Filtere nach dem angegebenen Locale (falls angegeben)
+3. Überspringe `locale/common/` (das ist ein Referenz-File, kein echtes Locale)
+4. Lies die `.po` Files und erstelle eine Übersicht: Locale, Anzahl Einträge, leere msgstr, fuzzy-Einträge
+
+### Phase 2: Kontext-Analyse
+
+Um kontextgerechte Übersetzungen sicherzustellen, lies diese Kontext-Quellen:
+
+1. **`scheduler/choices.py`** — Enthält Sport-spezifische Choice-Tuples mit `gettext_lazy`. Hier ist der Fachkontext (Sport/Liga-Planung) am klarsten.
+2. **Source-Referenzen in .po** — Jeder `msgid` hat `#:` Kommentare die zeigen, wo der String verwendet wird. Nutze diese um den Kontext zu verstehen:
+ - Strings aus `templates/` → UI-Labels, Buttons, Überschriften
+ - Strings aus `models.py` → Feld-Labels, verbose_names
+ - Strings aus `choices.py` → Dropdown-/Select-Optionen
+ - Strings aus `views.py` → Nachrichten, Fehlermeldungen
+ - Strings aus `helpers.py` → Solver-/System-Meldungen
+
+### Phase 3: Qualitätsprüfung
+
+Prüfe jede Übersetzung auf diese Kriterien:
+
+#### Kritisch
+- **Leere Übersetzungen**: `msgstr ""` wo eine Übersetzung erwartet wird
+- **Fuzzy-Markierungen**: `#, fuzzy` Einträge die manuell bestätigt werden müssen
+- **Falsche Platzhalter**: `%(name)s`, `{0}`, `%d` müssen exakt übereinstimmen
+
+#### Kontext
+- **Sport-Fachbegriffe**: Im Kontext von Liga-Planung die richtigen Begriffe verwenden:
+ - "Match" → "Spiel" (nicht "Übereinstimmung")
+ - "Day" (als Spieltag) → "Spieltag" (nicht "Tag")
+ - "Round" → "Runde" oder "Spieltag" je nach Kontext
+ - "Home/Away" → "Heim/Auswärts"
+ - "Fixture" → "Begegnung" oder "Ansetzung"
+ - "Draw" → "Auslosung" (nicht "Zeichnung")
+ - "Group" (im Turnier) → "Gruppe"
+ - "Seed" → "Setzliste"/"gesetzt" (nicht "Samen")
+ - "Clash" → "Überschneidung" oder "Konflikt"
+ - "Scenario" → "Szenario"
+ - "Constraint" → "Nebenbedingung" oder "Einschränkung"
+ - "Wish" → "Wunsch" (Planungswunsch)
+ - "Run" (Solver-Run) → "Lauf" oder "Durchlauf"
+ - "Slot" → "Zeitfenster" oder "Slot"
+
+#### Konsistenz
+- **Gleiche Begriffe gleich übersetzen**: Wenn "Scenario" einmal "Szenario" ist, muss es überall "Szenario" sein
+- **Stil-Konsistenz**: Formell/Informell einheitlich (Du vs. Sie). league-planner verwendet **Du-Form**
+- **Groß-/Kleinschreibung**: Deutsche Substantive groß, UI-Labels konsistent
+
+#### Stil
+- **Natürliche Sprache**: Übersetzungen sollen sich natürlich anfühlen, nicht wie maschinelle Übersetzung
+- **Kürze**: UI-Labels kurz halten, keine unnötigen Wörter
+- **Aktive Sprache**: "Szenario wurde erstellt" statt "Es wurde ein Szenario erstellt"
+
+### Phase 4: Änderungen anwenden
+
+Wenn NICHT `--dry-run`:
+
+1. Für jede gefundene Verbesserung, nutze das Edit-Tool um den `msgstr` zu aktualisieren
+2. Entferne `#, fuzzy` Markierungen wenn die Übersetzung korrekt bestätigt wurde
+3. Fülle leere `msgstr` aus, sofern der Kontext eine sichere Übersetzung erlaubt
+4. **WICHTIG**: Ändere NIEMALS `msgid` — nur `msgstr` darf geändert werden
+5. **WICHTIG**: Erhalte alle `#:` Source-Referenz-Kommentare unverändert
+6. **WICHTIG**: Erhalte Python-Format-Strings exakt (`%(name)s`, `{0}`, `%d` etc.)
+
+### Phase 5: Zusammenfassung
+
+Gib am Ende eine strukturierte Zusammenfassung:
+
+```
+## Translation Review — [Locale]
+
+### Statistik
+- Geprüfte Einträge: X
+- Verbesserungen: Y
+- Fehlende Übersetzungen (gefüllt): Z
+- Fuzzy aufgelöst: W
+- Unverändert: V
+
+### Änderungen
+| # | msgid | Alt | Neu | Grund |
+|---|-------|-----|-----|-------|
+| 1 | "..." | "..." | "..." | Kontext/Konsistenz/Stil |
+
+### Offene Punkte
+- [Einträge die manuelle Prüfung brauchen]
+```
+
+## Wichtige Regeln
+
+- **Konservativ sein**: Im Zweifel lieber nicht ändern als eine falsche Übersetzung einführen
+- **Kontext prüfen**: Immer die `#:` Source-Referenzen lesen bevor eine Übersetzung geändert wird
+- **Format-Strings erhalten**: `%(count)d Spiele` nicht `%(anzahl)d Spiele`
+- **Plural-Forms beachten**: `msgid_plural` / `msgstr[0]` / `msgstr[1]` korrekt handhaben
+- **Kein Overengineering**: Wenn eine Übersetzung gut genug ist, nicht ändern nur um sie "schöner" zu machen
+- **Locale `en`**: Englische .po Files haben typischerweise leere msgstr — das ist korrekt, da `msgid` bereits Englisch ist. Diese NICHT ausfüllen.
+- **Locale `common`**: Ist ein Referenz-File, nicht bearbeiten