tl;dr: Ein Mini-Tool für Gruppenaufteilungen in Workshops – lokal oder im Browser. Kann Zufallsgruppen, Balancierung, individuelle Rundenpläne und Begegnungsvisualisierung. Kein Overkill, aber mehr als nur „Namen mischen“.
https://www.visimon.de/go/breakout
Als vor ein paar Jahren wegen Corona das Homeoffice der neue Standard und wir alle zu Improvisationskünstler*innen für Zusammenarbeit und Lebendigkeit im digitalen Raum wurden, waren digitale Icebreaker, Whiteboards, Zufallsgeneratoren, etc. in aller Munde. Wir sammelten und teilten entsprechende Links wie Kamelle an Karneval.
Dank KI kann ich kleine Tools inzwischen selber bauen. Sie lösen oft nur ein einzelnes, sehr spezifisches Problem. Nicht mehr, nicht weniger. Manchmal entsteht dabei etwas, das ich vor Jahren noch irgendwo im Web hätte finden müssen. So wie dieser Breakout-Room-Generator.
Das Problem: Gruppen einteilen, ohne dass es langweilig wird
Stell dir vor, du leitest einen Workshop: online, hybrid oder vor Ort. Du willst Gruppenarbeit einbauen, aber nicht immer dieselben Leute zusammenstecken. Vielleicht kennen sich die Teilnehmenden noch gar nicht, und du möchtest, dass sie sich in den Kleingruppen besser vernetzen.
Bisher habe ich das mit Excel gemacht: neue Tabelle, Namen kopieren, per Hand mischen. Später kamen dann Funktionen in Zoom und Teams dazu. Praktisch, doch bald merkte ich: Die Tools geben mir nicht genug Kontrolle. Und für virtuelle Workshops brauche ich das eh nur noch selten.
Kurz: Ich wollte etwas Einfacheres. Etwas, das genau das kann, was ich brauche – und sonst nichts.
Was das Tool kann (und was nicht)
Der Generator ist kein Alleskönner wie GroupMixer, aber er deckt die wichtigsten Szenarien ab:
Zufällige Gruppenaufteilung – Einfach Namen eintippen, Gruppenanzahl oder -größe festlegen, fertig.
Balancierte Gruppen – Das Tool verteilt die Teilnehmenden so, dass Gruppen möglichst ausgeglichen und Begegnungen möglichst variantenreich sind.
Individuelle Rundenpläne – Du kannst festlegen, wie viele Gruppen pro Runde entstehen sollen oder wie groß die Gruppen sein sollen.
Begegnungsmatrix – Visualisiert, wer sich schon getroffen hat und wer noch nicht. Praktisch, um Wiederholungen zu vermeiden.
Exportfunktion – Der gesamte Plan lässt sich als Text kopieren.
Keine komplexen Regeln – Keine „keep together“- oder „keep apart“-Funktionen wie bei GroupMixer.
Kein Server, keine Anmeldung – Alles läuft lokal im Browser. Keine Daten werden gespeichert.
Zwei Modi: Auto-Strategie oder Custom Outline
Das Tool bietet zwei Arbeitsweisen:
- Auto-Strategie – Das Tool generiert automatisch einen optimalen Ablaufplan:
- Schnell: Minimale Rundenanzahl, größere Gruppen für viele Begegnungen.
- Abwechselnd: Wechselt zwischen Gruppengrößen für ein dynamisches Erlebnis.
- Ausgewogen: Bevorzugt 3er- und 4er-Gruppen für optimale Interaktion.
- Custom Outline – Du legst selbst fest, wie viele Runden es geben soll und wie groß die Gruppen sein sollen. Das Tool generiert dann einen Plan, der möglichst viele verschiedene Begegnungen ermöglicht.
Für wen ist das?
Für alle, die:
- keine Lust auf Overkill haben, aber trotzdem mehr wollen als nur „Namen mischen“.
- keine Daten preisgeben möchten (weil alles lokal läuft).
- mal schnell etwas ausprobieren wollen, ohne sich in komplexe Oberflächen einzuarbeiten.
Und jetzt?
- Zum Download (https://www.visimon.de/breakout-room-generator-lite-local-html/)
- Zur Online-Version (https://www.visimon.de/go/breakout).
- Der Code steht auch als Text zur Verfügung.
Hier öffnen für den vollständigen html-Code.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Breakout Room Generator Lite & Local</title>
<style>
/* Nur lokale System-Schriften — kein @font-face, kein CDN, keine Netzwerk-Font-Requests */
* {
box-sizing: border-box;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
body {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
h1 {
color: white;
text-align: center;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
margin-bottom: 10px;
}
.subtitle {
color: rgba(255,255,255,0.9);
text-align: center;
margin-bottom: 25px;
}
.container {
background: white;
border-radius: 15px;
padding: 25px;
margin-bottom: 20px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
h2 {
color: #667eea;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
margin-top: 0;
}
textarea {
width: 100%;
height: 120px;
padding: 15px;
border: 2px solid #ddd;
border-radius: 10px;
font-size: 16px;
resize: vertical;
}
textarea:focus {
outline: none;
border-color: #667eea;
}
button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 15px 30px;
font-size: 18px;
border-radius: 10px;
cursor: pointer;
margin: 10px 5px 10px 0;
transition: transform 0.2s, box-shadow 0.2s;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
}
button:disabled {
background: #ccc;
cursor: not-allowed;
transform: none;
}
.btn-small { padding: 10px 20px; font-size: 14px; }
.btn-example { background: #95a5a6; }
.btn-reset { background: #e74c3c; }
.btn-apply { background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); }
.btn-clear { background: #e74c3c; }
.btn-copy { background: #3498db; }
.btn-mark { background: #9b59b6; }
.mode-switch {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 12px 0 10px;
}
.mode-switch button {
margin: 0;
padding: 8px 14px;
font-size: 13px;
background: #eef1ff;
color: #445;
border: 2px solid #d7ddff;
box-shadow: none;
}
.mode-switch button.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border-color: transparent;
}
.mode-switch button:hover {
transform: none;
box-shadow: none;
border-color: #b9c5ff;
background: #e5eaff;
}
.mode-switch button.active:hover {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: transparent;
}
.custom-outline-box {
background: #f7f8ff;
border: 1px solid #dfe3ff;
border-radius: 10px;
padding: 12px;
margin-bottom: 10px;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.7);
}
.custom-outline-title {
font-weight: 600;
color: #4d5bb3;
margin-bottom: 8px;
}
.round-spec-row {
display: flex;
align-items: center;
gap: 8px;
overflow-x: auto;
padding: 2px 2px 8px;
scrollbar-width: thin;
scrollbar-color: #c9d1ff #eef1ff;
}
.round-spec-row::-webkit-scrollbar {
height: 8px;
}
.round-spec-row::-webkit-scrollbar-track {
background: #eef1ff;
border-radius: 999px;
}
.round-spec-row::-webkit-scrollbar-thumb {
background: #c9d1ff;
border-radius: 999px;
}
.round-spec-pill {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 6px;
border: 1px solid #cfd6ff;
background: #fff;
border-radius: 999px;
padding: 6px 10px;
min-height: 40px;
}
.round-spec-pill.conflict {
border-color: #efb1b1;
background: #fff4f4;
}
.round-spec-pill label {
font-size: 11px;
color: #666;
}
.round-spec-meta {
font-size: 10px;
color: #758;
white-space: nowrap;
}
.round-spec-pill.conflict .round-spec-meta {
color: #b24141;
font-weight: 600;
}
.round-spec-remove {
width: 20px;
height: 20px;
border-radius: 50%;
border: 1px solid #d7ddff;
background: #f4f6ff;
color: #6d78b0;
font-size: 12px;
line-height: 18px;
text-align: center;
cursor: pointer;
padding: 0;
margin: 0;
flex: 0 0 auto;
}
.round-spec-remove:hover {
background: #ffe9e9;
border-color: #f2b7b7;
color: #b34b4b;
transform: none;
box-shadow: none;
}
.round-spec-pill input,
.round-spec-pill select {
border: 1px solid #cbd1f5;
border-radius: 8px;
padding: 4px 6px;
font-size: 12px;
background: #fff;
color: #2f3763;
}
.round-spec-pill input {
width: 56px;
}
.round-spec-pill input:focus,
.round-spec-pill select:focus {
outline: none;
border-color: #7588ff;
box-shadow: 0 0 0 2px rgba(117,136,255,0.2);
}
.round-spec-pill.add-pill {
cursor: pointer;
color: #5c6bc0;
font-weight: 700;
user-select: none;
border-style: dashed;
}
.custom-outline-hint {
margin: 8px 0 0;
color: #666;
font-size: 12px;
line-height: 1.35;
}
.strategy-tabs {
display: flex;
gap: 0;
overflow-x: auto;
scroll-snap-type: x mandatory;
border-bottom: 3px solid #e0e0e0;
margin-bottom: 20px;
}
.strategy-tab {
flex: 0 0 auto;
padding: 15px 25px;
background: #f0f0f0;
border: none;
border-radius: 10px 10px 0 0;
cursor: pointer;
font-size: 14px;
font-weight: 600;
color: #666;
margin: 0;
white-space: nowrap;
}
.strategy-tab:hover {
background: #e0e0e0;
transform: none;
box-shadow: none;
}
.strategy-tab.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.round {
background: #f8f9fa;
border-radius: 10px;
padding: 15px;
margin: 10px 0;
border-left: 4px solid #667eea;
}
.round.has-repeats {
border-left-color: #f39c12;
background: #fffbf0;
}
.round-header {
font-weight: bold;
color: #667eea;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.round.has-repeats .round-header {
color: #d68910;
}
.round-info {
font-size: 12px;
color: #888;
font-weight: normal;
}
.group {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 10px 18px;
border-radius: 25px;
margin: 5px;
font-size: 14px;
box-shadow: 0 2px 10px rgba(102,126,234,0.3);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
}
.group:hover {
transform: scale(1.05);
box-shadow: 0 4px 15px rgba(102,126,234,0.5);
}
.group.size-2 { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }
.group.size-3 { background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); }
.group.size-4 { background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); }
.group.size-5 { background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); }
.group.size-6 { background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%); color: #333; }
.group.completed {
background: #999 !important;
color: #eee !important;
box-shadow: none !important;
cursor: default;
}
.group.completed:hover {
transform: none;
box-shadow: none !important;
}
.group u {
text-decoration-color: rgba(255,255,255,0.8);
text-decoration-thickness: 2px;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 15px;
margin: 15px 0;
}
.stat-box {
background: #f0f0f0;
padding: 15px;
border-radius: 10px;
text-align: center;
}
.stat-number {
font-size: 24px;
font-weight: bold;
color: #667eea;
}
.stat-label {
font-size: 10px;
color: #666;
margin-top: 5px;
}
.scenario-card {
background: white;
border: 2px solid #e0e0e0;
border-radius: 12px;
padding: 20px;
margin: 15px 0;
}
.scenario-card:hover {
border-color: #667eea;
box-shadow: 0 5px 20px rgba(102,126,234,0.2);
}
.scenario-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 15px;
}
.scenario-title {
font-size: 18px;
font-weight: bold;
color: #667eea;
}
.scenario-badge {
background: #38ef7d;
color: white;
padding: 5px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: bold;
}
.scenario-badge.warning { background: #f39c12; }
.scenario-stats {
display: flex;
gap: 20px;
flex-wrap: wrap;
margin-bottom: 15px;
font-size: 14px;
color: #666;
}
.scenario-stat strong { color: #333; }
.scenario-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 15px;
}
.config-info {
background: #e8f4fd;
border-left: 4px solid #3498db;
padding: 12px 15px;
margin: 10px 0;
border-radius: 0 8px 8px 0;
font-size: 14px;
}
.config-info.success {
background: #e8f8f0;
border-left-color: #27ae60;
}
.config-info.warning {
background: #fef9e7;
border-left-color: #f39c12;
}
#customPlanContent .round {
border-left-width: 5px;
}
.path-visual {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin: 15px 0;
padding: 15px;
background: #f8f9fa;
border-radius: 10px;
}
.path-step {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 8px 15px;
border-radius: 20px;
font-size: 14px;
font-weight: 500;
}
.path-step.size-2 { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }
.path-step.size-3 { background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); }
.path-step.size-4 { background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); }
.path-step.size-5 { background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); }
.path-step.size-6 { background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%); color: #333; }
.path-step.has-repeats {
border: 2px dashed rgba(255,255,255,0.7);
}
.path-arrow {
color: #999;
font-weight: bold;
font-size: 18px;
}
.strategy-description {
background: #f8f9fa;
padding: 15px;
border-radius: 10px;
margin-bottom: 15px;
font-size: 14px;
color: #555;
border-left: 4px solid #667eea;
}
.quality-indicator {
display: flex;
gap: 15px;
flex-wrap: wrap;
margin: 15px 0;
padding: 15px;
background: #f0f0f0;
border-radius: 10px;
}
.quality-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.quality-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.quality-dot.good { background: #27ae60; }
.quality-dot.medium { background: #f39c12; }
.quality-dot.poor { background: #e74c3c; }
.matrix-container {
overflow-x: auto;
margin: 20px 0;
}
table {
border-collapse: collapse;
font-size: 13px;
}
th, td {
border: 1px solid #ddd;
padding: 6px;
text-align: center;
min-width: 50px;
height: 40px;
}
th {
background: #667eea;
color: white;
font-weight: 600;
font-size: 11px;
}
.name-header {
background: #764ba2;
color: white;
text-align: left;
font-weight: 600;
padding-left: 10px;
min-width: 80px;
}
.matrix-cell {
cursor: pointer;
user-select: none;
font-size: 18px;
}
.matrix-cell:hover { background: #e8e8e8; }
.matrix-cell.state-off { background: #f8f8f8; color: #ddd; }
.matrix-cell.state-planned { background: #fff3cd; color: #856404; }
.matrix-cell.state-done { background: #d4edda; color: #155724; }
.cell-self { background: #e9ecef; color: #aaa; }
.matrix-corner-reset {
cursor: pointer;
user-select: none;
font-size: 16px;
line-height: 1;
min-width: 50px;
background: #eef1ff;
color: #5c6bc0;
transition: background 0.15s;
}
.matrix-corner-reset:hover {
background: #dfe6ff;
}
.matrix-corner-reset.disabled {
cursor: not-allowed;
opacity: 0.45;
background: #e9ecef;
color: #aaa;
}
.participant-count {
font-size: 14px;
color: #666;
margin: 10px 0;
line-height: 1.4;
}
.participant-count.warning { color: #e74c3c; }
.participant-count.ok { color: #27ae60; }
.progress-bar {
width: 100%;
height: 35px;
background: #e0e0e0;
border-radius: 18px;
overflow: hidden;
margin: 15px 0;
}
.progress-fill {
height: 100%;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 14px;
min-width: 60px;
}
.progress-fill.low { background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%); }
.progress-fill.medium { background: linear-gradient(135deg, #f39c12 0%, #d68910 100%); }
.progress-fill.high { background: linear-gradient(135deg, #27ae60 0%, #1e8449 100%); }
.progress-fill.complete { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
.matrix-legend {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin: 15px 0;
padding: 15px;
background: #f8f9fa;
border-radius: 10px;
font-size: 14px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
}
.legend-symbol {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
font-size: 16px;
}
.coverage-display {
font-size: 48px;
font-weight: bold;
text-align: center;
padding: 20px;
border-radius: 15px;
margin: 15px 0;
}
.coverage-display.low { background: linear-gradient(135deg, #ffe6e6 0%, #ffcccc 100%); color: #c0392b; }
.coverage-display.medium { background: linear-gradient(135deg, #fff9e6 0%, #fff0b3 100%); color: #d68910; }
.coverage-display.high { background: linear-gradient(135deg, #e6ffe6 0%, #b3ffb3 100%); color: #1e8449; }
.coverage-display.complete { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; }
.info-text {
text-align: center;
font-size: 14px;
color: #666;
margin-top: 10px;
}
.selected-rounds-container {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 10px;
margin: 20px 0;
}
.selected-rounds-container h3 {
color: white;
margin: 0 0 15px 0;
}
.selected-rounds-container .click-hint {
color: rgba(255,255,255,0.85);
}
.selected-rounds-container .round {
background: rgba(255,255,255,0.15);
border-left-color: rgba(255,255,255,0.5);
}
.selected-rounds-container .round.has-repeats {
background: rgba(243,156,18,0.3);
border-left-color: #f39c12;
}
.selected-rounds-container .round-header { color: white; }
.collapsible-header {
cursor: pointer;
user-select: none;
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
font-weight: 600;
color: #555;
}
.collapsible-header::after { content: '▼'; font-size: 14px; }
.collapsible-header.collapsed::after { content: '▶'; }
.collapsible-content {
overflow: hidden;
max-height: 50000px;
}
.collapsible-content.collapsed {
max-height: 0;
padding: 0;
margin: 0;
}
.extra-hint {
background: #fef9e7;
border: 1px solid #f39c12;
border-radius: 8px;
padding: 10px 15px;
margin: 10px 0;
font-size: 13px;
color: #856404;
}
.loading {
text-align: center;
padding: 40px;
font-size: 18px;
color: #667eea;
}
.click-hint {
font-size: 12px;
color: #888;
font-style: italic;
margin: 10px 0;
}
.scroll-hint {
text-align: center;
font-size: 12px;
color: #888;
margin-bottom: 10px;
}
.app-footer {
text-align: center;
font-size: 12px;
color: rgba(255,255,255,0.78);
margin: 30px 0 10px;
}
.app-footer a {
color: rgba(255,255,255,0.9);
text-decoration: underline;
text-underline-offset: 2px;
}
.app-footer a:hover {
color: #ffffff;
}
.lang-switcher {
position: fixed;
top: 10px;
right: 10px;
display: flex;
gap: 4px;
z-index: 1000;
}
.lang-btn {
border: 1px solid rgba(255,255,255,0.7);
background: rgba(255,255,255,0.2);
color: white;
border-radius: 999px;
padding: 3px 5px;
font-size: 10px;
line-height: 1;
cursor: pointer;
}
.lang-btn.active {
background: rgba(255,255,255,0.95);
color: #333;
}
@media (max-width: 768px) {
body { padding: 10px; }
.container { padding: 15px; }
th, td { padding: 4px; font-size: 11px; min-width: 40px; }
button { padding: 12px 15px; font-size: 14px; }
.mode-switch button { font-size: 12px; padding: 7px 10px; }
.round-spec-pill { padding: 6px 8px; }
.round-spec-pill input { width: 48px; }
.round-spec-pill label { font-size: 10px; }
}
</style>
</head>
<body>
<!--
================================================================================
Breakout Room Generator Lite & Local
https://www.visimon.de
================================================================================
VERWENDUNGSZWECK
Einseitige Web-App zur Planung von Breakout-Räumen in Online-Trainings.
Teilnehmernamen eingeben, Gruppen pro Runde berechnen, Begegnungen
visualisieren und den Workshop-Ablauf exportieren.
Zwei Modi:
- Auto-Strategie: optimierte Ablaufpläne (schnell / abwechselnd / ausgewogen)
- Custom Outline: feste Runden-Vorgabe (Anzahl Gruppen oder Personen pro Runde)
Kein Server, keine Cookies, keine externen Schriften oder Skripte:
läuft vollständig im Browser (clientseitig). Eingegebene Namen verbleiben
nur lokal im Gerät des Nutzers (keine Übermittlung an Dritte).
DATENSCHUTZ (GDPR-orientiert, keine Rechtsberatung)
Dieses Tool lädt beim Öffnen keine Ressourcen von Drittanbietern.
Optionaler Link im Footer (Visimon.de) wird erst beim Klick aufgerufen.
DOWNLOAD-VARIANTE (Lite & Local)
Diese Datei ist für den Download gedacht: offline im Browser öffnen,
ohne Server und ohne Datenübertragung an Dritte.
EINBINDUNG
- Als einzelne .html-Datei speichern und lokal öffnen, oder
- Auf der Website als Download anbieten (komplett lauffähig ohne Build).
- Empfohlen: Datei nicht zerlegen; CSS und JavaScript sind bewusst inline.
MATRIX (Begegnungsmatrix)
Zustände pro Teilnehmerpaar: 0 = offen, 1 = geplant, 2 = erledigt.
Zurücksetzen:
- A1 (↩): Planungsstand wiederherstellen (Plan bleibt)
- Toggle „Alle erledigen / Alle zurücksetzen“: Massenstatus
- „Zurücksetzen“ (rot): kompletter Neustart inkl. Plan
LIZENZ
Copyright (c) 2026 Visimon.de
SPDX-License-Identifier: GPL-3.0-or-later
Freie Software unter GNU GPL v3 oder später. Weitergabe und Bearbeitung
erlaubt, sofern abgeleitete Werke unter derselben Lizenz stehen.
Ohne Gewähr; Nutzung auf eigenes Risiko.
Erstellt mit Unterstützung von Cursor AI.
================================================================================
-->
<div class="lang-switcher" aria-label="Language switcher">
<button id="btnLangDe" class="lang-btn active" onclick="setLanguage('de')" aria-label="Deutsch">🇩🇪</button>
<button id="btnLangEn" class="lang-btn" onclick="setLanguage('en')" aria-label="English">🇬🇧</button>
</div>
<h1 id="titleMain">🎯 Breakout Room Generator Lite & Local</h1>
<p id="subtitleMain" class="subtitle">Optimale Gruppeneinteilung für Online-Trainings</p>
<div class="container">
<h2 id="titleInput">👥 Teilnehmer eingeben</h2>
<textarea id="txtNames" oninput="onNamesInput()" placeholder="Namen eingeben (einer pro Zeile oder durch Komma getrennt)..."></textarea>
<div class="mode-switch">
<button id="btnModeAuto" class="active" onclick="setGeneratorMode('auto')">⚙️ Auto-Strategie</button>
<button id="btnModeCustom" onclick="setGeneratorMode('custom')">🧩 Custom Outline</button>
</div>
<div id="customOutlineBox" class="custom-outline-box" style="display:none">
<div class="custom-outline-title" id="customOutlineTitle">Runden-Outline festlegen</div>
<div class="round-spec-row" id="roundSpecRow"></div>
<p class="custom-outline-hint" id="customOutlineHint">Pro Runde: Zahl + Einheit (Gruppen oder Personen pro Gruppe).</p>
</div>
<p class="participant-count" id="pCountInfo">Erkannte Teilnehmer: <strong id="pCountNum">0</strong></p>
<button id="btnCalc" onclick="onCalculate()" disabled>🚀 Gruppen berechnen</button>
<button id="btnResetNames" class="btn-small btn-reset" onclick="onResetNames()">🗑️ Leeren</button>
<button class="btn-small btn-example" data-example-n="6" onclick="onExample(6)">6 TN</button>
<button class="btn-small btn-example" data-example-n="8" onclick="onExample(8)">8 TN</button>
<button class="btn-small btn-example" data-example-n="10" onclick="onExample(10)">10 TN</button>
<button class="btn-small btn-example" data-example-n="12" onclick="onExample(12)">12 TN</button>
<button class="btn-small btn-example" data-example-n="15" onclick="onExample(15)">15 TN</button>
<button class="btn-small btn-example" data-example-n="20" onclick="onExample(20)">20 TN</button>
</div>
<div id="mainResults"></div>
<p class="app-footer">created 2026 by <a href="https://www.visimon.de" target="_blank" rel="noopener noreferrer">Visimon.de</a></p>
<script>
// =============================================================================
// Breakout Room Generator Lite & Local — Anwendungslogik (Single-File, kein Build)
// =============================================================================
// Globaler State:
// participants, configurations, strategies — Berechnung & Auto-Modus
// generatorMode ('auto'|'custom'), roundSpecs — manuelle Outline
// selectedRounds — aktueller Plan; matrix[i][j] — 0 offen, 1 geplant, 2 erledigt
// completedGroups — per Klick als erledigt markierte Gruppen (UI)
// =============================================================================
var NAMES = ["Anna","Bernd","Clara","Daniel","Eva","Frank","Greta","Henrik","Iris","Jonas",
"Katja","Lukas","Marie","Nico","Olga","Paul","Quirin","Rosa","Stefan","Tina"];
var participants = [];
var configurations = {};
var strategies = {};
var activeStrategy = 'balanced';
var selectedRounds = [];
var matrix = {};
var completedGroups = {};
var currentLang = 'de';
var generatorMode = 'auto';
var roundSpecs = [];
var customPlanMeta = { warnings: [], impossible: [], conflicts: [] };
var roundSpecDiagnostics = { items: [], conflictCount: 0 };
var hasCalculatedResults = false;
// ============ I18N (de/en) ============
var I18N = {
de: {
subtitle: "Optimale Gruppeneinteilung für Online-Trainings",
titleInput: "👥 Teilnehmer eingeben",
placeholderNames: "Namen eingeben (einer pro Zeile oder durch Komma getrennt)...",
participantsDetected: "Erkannte Teilnehmer",
minShort: "min. 4",
calcBtn: "🚀 Gruppen berechnen",
modeAuto: "⚙️ Auto-Strategie",
modeCustom: "🧩 Custom Outline",
customOutlineTitle: "Runden-Outline festlegen",
customOutlineHint: "Pro Runde: Zahl + Einheit (Gruppen oder Personen pro Gruppe).",
customRound: "R",
removeRound: "Runde entfernen",
groupsUnit: "Gruppen",
personsUnit: "Personen",
addRound: "+ Runde",
invalidRoundSpec: "Bitte valide Outline-Werte eintragen.",
specRangeInfo: "{min}-{max} {unit}",
specConflictTooSmall: "zu klein",
specConflictTooLarge: "zu groß",
specConflictNotNumber: "ungültig",
roundConflictCount: "{n} Runde(n) außerhalb der Grenzen",
roundConflictDetail: "R{r}: {value} {unit} (erlaubt {min}-{max}, genutzt {used})",
clearBtn: "🗑️ Leeren",
participantShort: "TN",
loading: "Berechne optimale Szenarien...",
overviewTitle: "📊 Übersicht für {n} Teilnehmer",
participants: "Teilnehmer",
encountersNeeded: "Begegnungen nötig",
groupSizes: "Gruppengrößen",
minRounds: "Min. Runden",
recStrategies: "🏆 Empfohlene Strategien",
customPlanTitle: "🧩 Custom-Outline-Plan",
customPlanDesc: "Die Runden wurden gemäß deiner Outline mit maximaler Abwechslung generiert.",
customRegenerate: "🔄 Neu generieren",
customIssueSummary: "⚠️ Hinweise zur Outline",
notFeasible: "nicht umsetzbar",
withRepeatsShort: "mit Wiederholungen",
strategyHint: "← Strategie wählen (wischen/scrollen) →",
strategyEff: "⚡ Schnell",
strategyVar: "🔀 Abwechselnd",
strategyBal: "⭐ Ausgewogen",
singleGroupSizes: "🎯 Einzelne Gruppengrößen",
matrixTitle: "📋 Begegnungsmatrix",
open: "Offen",
planned: "Geplant",
done: "Erledigt",
reset: "🗑️ Zurücksetzen",
markAllDone: "✅ Alle erledigen",
markAllReset: "↩️ Alle zurücksetzen",
matrixRestorePlan: "Auf Planungsstand zurücksetzen",
copy: "📋 Kopieren",
descEff: "⚡ <strong>Schnell:</strong> Minimale Rundenanzahl. Wählt größere Gruppen um schnell viele Begegnungen zu erzeugen.",
descVar: "🔀 <strong>Abwechselnd:</strong> Wechselt zwischen Gruppengrößen für ein dynamisches Erlebnis. Mal 3er, mal 4er, mal 5er.",
descBal: "⭐ <strong>Ausgewogen:</strong> Bevorzugt 3er und 4er Gruppen für optimale Interaktion. Vermeidet eintönige 2er-Runden am Ende.",
coverage: "Abdeckung",
roundsWithRep: "Runden mit Wdh.",
maxSameSize: "Max. {n}x gleiche Größe",
no2end: "Kein 2er am Ende",
pairEndCount: "{n} 2er am Ende",
roundsWithNames: "📋 Alle {n} Runden mit Namen anzeigen",
clickDoneHint: "💡 Klicke auf eine Gruppe, um sie als erledigt zu markieren oder den Status zurückzusetzen. <u>Unterstrichene Namen</u> = treffen sich erneut.",
asPlanned: "☑️ Als geplant",
asDone: "✅ Als erledigt",
round: "Runde",
containsRep: "enthält Wdh.",
groupsAbout: "Gruppen à ~{n}",
groupsTitle: "{n}er-Gruppen",
noRep100: "100% ohne Wdh.",
withRep100: "100% mit Wdh.",
maxPct: "Max {n}%",
groupsPerRound: "Gruppen/Runde",
withPers: "{n}× mit {m} Pers.",
strictRounds: "strikte Runden",
extraRounds: "Extra-Runden",
roundsNoRep: "📋 {n} Runden ohne Wiederholung ({pct}%)",
extraFor100: "⚡ {n} Extra-Runden für 100% (mit Wiederholungen)",
extraHint: "⚠️ <u>Unterstrichene Namen</u> = Personen die sich in dieser Runde erneut begegnen (kennen sich bereits).",
clickGroupHint: "💡 Klicke auf eine Gruppe, um sie als erledigt zu markieren oder den Status zurückzusetzen.",
all: "Alle",
plusExtra: "+ Extra (100%)",
completedPct: "{n}% erledigt",
doneOfTotal: "{done} von {total} erledigt | {planned} geplant",
currentPlan: "📌 Aktuelle Planung ({n} Runden)",
clickGroupSelectedHint: "💡 Klicke auf Gruppen, um sie als erledigt zu markieren oder den Status zurückzusetzen. <u>Unterstrichen</u> = erneute Begegnung.",
copied: "✓ Kopiert!",
copyHeader: "BREAKOUT-RAUM EINTEILUNG",
copyParticipants: "TEILNEHMER ({n}):",
copyPhases: "GRUPPENPHASEN ({n} Runden)",
copyMode: "MODUS: {mode}",
copyRound: "RUNDE {n} ({gs}er-Gruppen){rep}:",
copyRepTag: " [mit Wdh.]",
room: "Raum",
groupsWord: "Gruppen",
status: "STATUS",
of: "von",
inRounds: "in",
rounds: "Runden",
withRepeatedEncounters: "mit wiederholten Begegnungen"
},
en: {
subtitle: "Optimal group assignment for online trainings",
titleInput: "👥 Enter participants",
placeholderNames: "Enter names (one per line or separated by commas)...",
participantsDetected: "Detected participants",
minShort: "min. 4",
calcBtn: "🚀 Calculate groups",
modeAuto: "⚙️ Auto strategy",
modeCustom: "🧩 Custom outline",
customOutlineTitle: "Define round outline",
customOutlineHint: "Per round: number + unit (groups or people per group).",
customRound: "R",
removeRound: "Remove round",
groupsUnit: "Groups",
personsUnit: "People",
addRound: "+ Round",
invalidRoundSpec: "Please enter valid outline values.",
specRangeInfo: "{min}-{max} {unit}",
specConflictTooSmall: "too small",
specConflictTooLarge: "too large",
specConflictNotNumber: "invalid",
roundConflictCount: "{n} round(s) out of allowed range",
roundConflictDetail: "R{r}: {value} {unit} (allowed {min}-{max}, used {used})",
clearBtn: "🗑️ Clear",
participantShort: "P",
loading: "Calculating optimal scenarios...",
overviewTitle: "📊 Overview for {n} participants",
participants: "Participants",
encountersNeeded: "Required encounters",
groupSizes: "Group sizes",
minRounds: "Min. rounds",
recStrategies: "🏆 Recommended strategies",
customPlanTitle: "🧩 Custom outline plan",
customPlanDesc: "Rounds were generated from your outline with maximum variety.",
customRegenerate: "🔄 Regenerate",
customIssueSummary: "⚠️ Outline notes",
notFeasible: "not feasible",
withRepeatsShort: "with repeats",
strategyHint: "← Choose strategy (swipe/scroll) →",
strategyEff: "⚡ Fast",
strategyVar: "🔀 Alternating",
strategyBal: "⭐ Balanced",
singleGroupSizes: "🎯 Individual group sizes",
matrixTitle: "📋 Encounter matrix",
open: "Open",
planned: "Planned",
done: "Done",
reset: "🗑️ Reset",
markAllDone: "✅ Mark all done",
markAllReset: "↩️ Reset all",
matrixRestorePlan: "Restore planning state",
copy: "📋 Copy",
descEff: "⚡ <strong>Fast:</strong> Minimal number of rounds. Uses larger groups to create many encounters quickly.",
descVar: "🔀 <strong>Alternating:</strong> Switches group sizes for a dynamic experience. Sometimes 3s, 4s, then 5s.",
descBal: "⭐ <strong>Balanced:</strong> Prefers groups of 3 and 4 for optimal interaction. Avoids monotonous pairs at the end.",
coverage: "coverage",
roundsWithRep: "rounds with repeats",
maxSameSize: "Max. {n}x same size",
no2end: "No pairs at the end",
pairEndCount: "{n} pairs at the end",
roundsWithNames: "📋 Show all {n} rounds with names",
clickDoneHint: "💡 Click a group to mark it as done or reset its status. <u>Underlined names</u> = they meet again.",
asPlanned: "☑️ Mark as planned",
asDone: "✅ Mark as done",
round: "Round",
containsRep: "contains repeats",
groupsAbout: "groups of ~{n}",
groupsTitle: "Groups of {n}",
noRep100: "100% without repeats",
withRep100: "100% with repeats",
maxPct: "Max {n}%",
groupsPerRound: "groups/round",
withPers: "{n}× with {m} people",
strictRounds: "strict rounds",
extraRounds: "extra rounds",
roundsNoRep: "📋 {n} rounds without repetition ({pct}%)",
extraFor100: "⚡ {n} extra rounds for 100% (with repeats)",
extraHint: "⚠️ <u>Underlined names</u> = people who meet again in this round (already know each other).",
clickGroupHint: "💡 Click a group to mark it as done or reset its status.",
all: "All",
plusExtra: "+ Extra (100%)",
completedPct: "{n}% done",
doneOfTotal: "{done} of {total} done | {planned} planned",
currentPlan: "📌 Current plan ({n} rounds)",
clickGroupSelectedHint: "💡 Click groups to mark them as done or reset their status. <u>Underlined</u> = repeated encounter.",
copied: "✓ Copied!",
copyHeader: "BREAKOUT ROOM PLAN",
copyParticipants: "PARTICIPANTS ({n}):",
copyPhases: "GROUP PHASES ({n} rounds)",
copyMode: "MODE: {mode}",
copyRound: "ROUND {n} (groups of {gs}){rep}:",
copyRepTag: " [with repeats]",
room: "Room",
groupsWord: "groups",
status: "STATUS",
of: "of",
inRounds: "in",
rounds: "rounds",
withRepeatedEncounters: "with repeated encounters"
}
};
function t(key, vars) {
var txt = (I18N[currentLang] && I18N[currentLang][key]) || I18N.de[key] || key;
if (!vars) return txt;
for (var k in vars) txt = txt.replace(new RegExp("\\{" + k + "\\}", "g"), vars[k]);
return txt;
}
function setLanguage(lang) {
currentLang = lang === 'en' ? 'en' : 'de';
document.documentElement.lang = currentLang;
document.getElementById("btnLangDe").classList.toggle("active", currentLang === "de");
document.getElementById("btnLangEn").classList.toggle("active", currentLang === "en");
updateStaticTexts();
onNamesInput();
if (hasCalculatedResults && participants.length >= 4) renderResults();
else updateMarkAllButtonLabel();
}
function updateStaticTexts() {
document.getElementById("subtitleMain").textContent = t("subtitle");
document.getElementById("titleInput").textContent = t("titleInput");
document.getElementById("txtNames").placeholder = t("placeholderNames");
document.getElementById("btnCalc").textContent = t("calcBtn");
document.getElementById("btnResetNames").textContent = t("clearBtn");
document.getElementById("btnModeAuto").textContent = t("modeAuto");
document.getElementById("btnModeCustom").textContent = t("modeCustom");
document.getElementById("customOutlineTitle").textContent = t("customOutlineTitle");
document.getElementById("customOutlineHint").textContent = t("customOutlineHint");
var exampleBtns = document.querySelectorAll("button.btn-example[data-example-n]");
for (var i = 0; i < exampleBtns.length; i++) {
var n = exampleBtns[i].getAttribute("data-example-n");
exampleBtns[i].textContent = n + " " + t("participantShort");
}
}
// ============ EINGABE & CUSTOM OUTLINE ============
// Modus-Umschalter, RoundSpec-Pills, transparente Grenzprüfung (warnen, nicht blockieren)
function defaultRoundSpecs() {
var specs = [];
for (var i = 0; i < 5; i++) specs.push({ value: 3, unit: 'groups' });
return specs;
}
function ensureRoundSpecs() {
if (!roundSpecs || roundSpecs.length === 0) roundSpecs = defaultRoundSpecs();
}
function renderRoundSpecInputs(diagnostics) {
ensureRoundSpecs();
var row = document.getElementById("roundSpecRow");
if (!row) return;
diagnostics = diagnostics || analyzeAllRoundSpecs(roundSpecs, participants.length);
var h = "";
for (var i = 0; i < roundSpecs.length; i++) {
var spec = roundSpecs[i];
var diag = diagnostics.items[i];
var pillCls = 'round-spec-pill' + (diag && diag.isConflict ? ' conflict' : '');
h += '<div class="' + pillCls + '">';
h += '<label>' + t("customRound") + ' ' + (i + 1) + '</label>';
h += '<input type="number" min="1" value="' + spec.value + '" onchange="updateRoundSpec(' + i + ', this.value, null)">';
h += '<select onchange="updateRoundSpec(' + i + ', null, this.value)">';
h += '<option value="groups"' + (spec.unit === 'groups' ? ' selected' : '') + '>' + t("groupsUnit") + '</option>';
h += '<option value="persons"' + (spec.unit === 'persons' ? ' selected' : '') + '>' + t("personsUnit") + '</option>';
h += '</select>';
if (diag) {
var unitLabel = t(diag.unit === 'groups' ? 'groupsUnit' : 'personsUnit');
var meta = t("specRangeInfo", { min: diag.minAllowed, max: diag.maxAllowed, unit: unitLabel });
if (diag.isConflict) {
var reasonKey = diag.reasonKey === 'tooSmall' ? 'specConflictTooSmall' : (diag.reasonKey === 'tooLarge' ? 'specConflictTooLarge' : 'specConflictNotNumber');
meta += ' · ' + t(reasonKey);
}
h += '<span class="round-spec-meta">' + meta + '</span>';
}
h += '<button class="round-spec-remove" onclick="removeRoundSpec(' + i + ')" title="' + t("removeRound") + '">×</button>';
h += '</div>';
}
h += '<div class="round-spec-pill add-pill" onclick="addRoundSpec()">' + t("addRound") + '</div>';
row.innerHTML = h;
}
function addRoundSpec() {
ensureRoundSpecs();
roundSpecs.push({ value: 3, unit: 'groups' });
renderRoundSpecInputs();
onNamesInput();
}
function removeRoundSpec(idx) {
ensureRoundSpecs();
if (roundSpecs.length <= 1) return;
roundSpecs.splice(idx, 1);
renderRoundSpecInputs();
onNamesInput();
}
function updateRoundSpec(idx, value, unit) {
ensureRoundSpecs();
if (!roundSpecs[idx]) return;
if (value !== null) roundSpecs[idx].value = parseInt(value, 10) || 0;
if (unit !== null) roundSpecs[idx].unit = unit;
onNamesInput();
}
function setGeneratorMode(mode) {
generatorMode = mode === 'custom' ? 'custom' : 'auto';
document.getElementById("btnModeAuto").classList.toggle("active", generatorMode === "auto");
document.getElementById("btnModeCustom").classList.toggle("active", generatorMode === "custom");
var box = document.getElementById("customOutlineBox");
if (box) box.style.display = generatorMode === 'custom' ? 'block' : 'none';
renderRoundSpecInputs();
onNamesInput();
}
function analyzeRoundSpec(spec, n) {
var unit = spec && spec.unit === 'persons' ? 'persons' : 'groups';
var rawValue = parseInt(spec && spec.value, 10);
var minAllowed = 2;
var maxAllowed = unit === 'groups' ? Math.max(2, Math.floor(n / 2)) : Math.max(2, n - 1);
var reasonKey = '';
if (!rawValue) reasonKey = 'notNumber';
else if (rawValue < minAllowed) reasonKey = 'tooSmall';
else if (rawValue > maxAllowed) reasonKey = 'tooLarge';
var effectiveValue = rawValue;
if (!effectiveValue || isNaN(effectiveValue)) effectiveValue = minAllowed;
if (effectiveValue < minAllowed) effectiveValue = minAllowed;
if (effectiveValue > maxAllowed) effectiveValue = maxAllowed;
return {
unit: unit,
value: rawValue,
minAllowed: minAllowed,
maxAllowed: maxAllowed,
effectiveValue: effectiveValue,
isConflict: !!reasonKey,
reasonKey: reasonKey
};
}
function analyzeAllRoundSpecs(specs, n) {
var items = [];
var conflictCount = 0;
for (var i = 0; i < specs.length; i++) {
var item = analyzeRoundSpec(specs[i], n);
items.push(item);
if (item.isConflict) conflictCount++;
}
return { items: items, conflictCount: conflictCount };
}
function onExample(n) {
document.getElementById("txtNames").value = NAMES.slice(0, n).join("\n");
onNamesInput();
}
function onResetNames() {
document.getElementById("txtNames").value = "";
participants = [];
hasCalculatedResults = false;
onNamesInput();
document.getElementById("mainResults").innerHTML = "";
}
function onNamesInput() {
var txt = document.getElementById("txtNames").value || "";
var arr = txt.split(/[\n,]+/);
var names = [];
for (var i = 0; i < arr.length; i++) {
var s = arr[i].replace(/^\s+|\s+$/g, "");
if (s.length > 0 && names.indexOf(s) === -1) names.push(s);
}
if (names.length > 30) names = names.slice(0, 30);
participants = names;
var count = names.length;
var info = document.getElementById("pCountInfo");
var btn = document.getElementById("btnCalc");
ensureRoundSpecs();
roundSpecDiagnostics = analyzeAllRoundSpecs(roundSpecs, count);
renderRoundSpecInputs(roundSpecDiagnostics);
if (count < 4) {
info.className = "participant-count warning";
info.innerHTML = t("participantsDetected") + ': <strong id="pCountNum">' + count + '</strong> <em>(' + t("minShort") + ')</em>';
btn.disabled = true;
} else if (generatorMode === 'custom' && roundSpecDiagnostics.conflictCount > 0) {
info.className = "participant-count warning";
info.innerHTML = t("participantsDetected") + ': <strong id="pCountNum">' + count + '</strong> · ' + t("roundConflictCount", { n: roundSpecDiagnostics.conflictCount });
btn.disabled = false;
} else {
info.className = "participant-count ok";
info.innerHTML = t("participantsDetected") + ': <strong id="pCountNum">' + count + '</strong> ✓';
btn.disabled = false;
}
}
function onCalculate() {
if (participants.length < 4) return;
document.getElementById("mainResults").innerHTML = '<div class="container"><div class="loading">' + t("loading") + '</div></div>';
setTimeout(doCalculate, 50);
}
// ============ KERNBERECHNUNG ============
// Auto: Konfigurationen pro Gruppengröße + Strategien (Beam Search)
// Custom: sequentielle Runden aus roundSpecs, Diversität maximiert
function doCalculate() {
var n = participants.length;
configurations = {};
completedGroups = {};
customPlanMeta = { warnings: [], impossible: [], conflicts: [] };
if (generatorMode === 'custom') {
initMatrix();
selectedRounds = generateCustomPlan(roundSpecs, roundSpecDiagnostics.items);
for (var sr = 0; sr < selectedRounds.length; sr++) {
applyRoundToMatrix(selectedRounds[sr].round, 1);
}
strategies = {};
hasCalculatedResults = true;
renderResults();
return;
}
for (var gs = 2; gs <= Math.floor(n / 2); gs++) {
var result = calculateStrictRounds(gs);
if (result.rounds.length > 0) {
configurations[gs] = result;
}
}
strategies = {
efficient: buildStrategy('efficient'),
varied: buildStrategy('varied'),
balanced: buildStrategy('balanced')
};
activeStrategy = 'balanced';
initMatrix();
selectedRounds = [];
hasCalculatedResults = true;
renderResults();
}
// --- Custom-Outline: Gruppengrößen fair verteilen (Rest ±1 Person) ---
function buildBalancedSizes(total, numGroups) {
var sizes = [];
var base = Math.floor(total / numGroups);
var remainder = total % numGroups;
for (var i = 0; i < numGroups; i++) sizes.push(base + (i < remainder ? 1 : 0));
return sizes;
}
function normalizeRoundSpec(spec, n, analysis) {
var unit = analysis ? analysis.unit : (spec.unit === 'persons' ? 'persons' : 'groups');
var value = analysis ? analysis.effectiveValue : parseInt(spec.value, 10);
if (!value || value < 2) value = 2;
if (unit === 'groups') {
var ng = value;
if (ng < 2) ng = 2;
if (ng > Math.floor(n / 2)) ng = Math.floor(n / 2);
return {
unit: 'groups',
value: analysis ? analysis.value : value,
effectiveValue: ng,
numGroups: ng,
sizes: buildBalancedSizes(n, ng),
gsLabel: '~' + Math.floor(n / ng)
};
}
var target = value;
if (target < 2) return null;
var numGroups = Math.round(n / target);
if (numGroups < 2) numGroups = 2;
var maxGroups = Math.floor(n / 2);
if (numGroups > maxGroups) numGroups = maxGroups;
if (numGroups < 2) return null;
return {
unit: 'persons',
value: analysis ? analysis.value : value,
effectiveValue: target,
numGroups: numGroups,
sizes: buildBalancedSizes(n, numGroups),
gsLabel: '~' + value
};
}
function evaluateRoundGroups(groups, met) {
var newPairs = 0;
var repeatPairs = 0;
var repeats = {};
for (var g = 0; g < groups.length; g++) {
for (var i = 0; i < groups[g].length; i++) {
for (var j = i + 1; j < groups[g].length; j++) {
var ia = participants.indexOf(groups[g][i]);
var ib = participants.indexOf(groups[g][j]);
var key = pairKey(ia, ib);
if (!met[key]) {
newPairs++;
} else {
repeatPairs++;
repeats[ia] = true;
repeats[ib] = true;
}
}
}
}
return { newPairs: newPairs, repeatPairs: repeatPairs, repeats: repeats };
}
function createCandidateRoundFromSizes(sizes) {
var indices = shuffledIndices(participants.length);
var groups = [];
var pos = 0;
for (var s = 0; s < sizes.length; s++) {
var size = sizes[s];
var group = [];
for (var i = 0; i < size; i++) {
group.push(participants[indices[pos++]]);
}
groups.push(group);
}
return groups;
}
function createBestRoundForSpec(normalized, met) {
var best = null;
for (var attempt = 0; attempt < 2800; attempt++) {
var groups = createCandidateRoundFromSizes(normalized.sizes);
var evalRes = evaluateRoundGroups(groups, met);
var score = evalRes.newPairs * 100 - evalRes.repeatPairs * 3;
if (!best || score > best.score || (score === best.score && evalRes.repeatPairs < best.repeatPairs)) {
best = {
score: score,
groups: groups,
newPairs: evalRes.newPairs,
repeatPairs: evalRes.repeatPairs,
repeats: evalRes.repeats
};
}
if (evalRes.repeatPairs === 0 && attempt > 200) break;
}
return best;
}
function generateCustomPlan(specs, analyses) {
var met = {};
var plan = [];
var n = participants.length;
customPlanMeta = { warnings: [], impossible: [], conflicts: [] };
analyses = analyses && analyses.length === specs.length ? analyses : analyzeAllRoundSpecs(specs, n).items;
for (var i = 0; i < specs.length; i++) {
var specAnalysis = analyses[i];
if (specAnalysis && specAnalysis.isConflict) {
customPlanMeta.conflicts.push({
round: i + 1,
analysis: specAnalysis
});
}
var normalized = normalizeRoundSpec(specs[i], n, specAnalysis);
if (!normalized) {
customPlanMeta.impossible.push(i + 1);
continue;
}
var best = createBestRoundForSpec(normalized, met);
if (!best) {
customPlanMeta.impossible.push(i + 1);
continue;
}
var round = {
groups: best.groups,
groupSize: Math.round(n / normalized.numGroups),
numGroups: normalized.numGroups
};
markPairs(round, met);
var hasRepeats = best.repeatPairs > 0;
if (hasRepeats) customPlanMeta.warnings.push(i + 1);
plan.push({
id: 'custom-' + i,
round: round,
gs: normalized.gsLabel,
hasRepeats: hasRepeats,
repeats: hasRepeats ? best.repeats : null,
roundSpec: normalized,
roundSpecAnalysis: specAnalysis || null
});
}
return plan;
}
// --- Pro feste Gruppengröße: strikte Runden (ohne Wdh.) + Extra-Runden (100 %) ---
function calculateStrictRounds(groupSize) {
var n = participants.length;
var numGroups = Math.floor(n / groupSize);
var remainder = n % groupSize;
if (numGroups < 2) {
return { rounds: [], extraRounds: [], groupSize: groupSize, numGroups: 0, remainder: 0 };
}
// Strenge Runden (keine wiederholten Paare innerhalb der Sequenz)
var rounds = [];
var metPairs = {};
for (var r = 0; r < 100; r++) {
var round = tryCreateStrictRound(groupSize, numGroups, remainder, metPairs);
if (!round) break;
for (var g = 0; g < round.length; g++) {
for (var i = 0; i < round[g].length; i++) {
for (var j = i + 1; j < round[g].length; j++) {
metPairs[pairKey(round[g][i], round[g][j])] = true;
}
}
}
rounds.push(indexRoundToNamed(round, groupSize, numGroups));
}
// Extra-Runden für 100 % Paarabdeckung (Wiederholungen erlaubt)
var extraRounds = [];
var totalPairs = (n * (n - 1)) / 2;
while (Object.keys(metPairs).length < totalPairs && extraRounds.length < 20) {
var extraRound = tryCreateExtraRound(groupSize, numGroups, remainder, metPairs);
if (!extraRound || extraRound.newPairs === 0) break;
for (var g = 0; g < extraRound.round.groups.length; g++) {
var grp = extraRound.round.groups[g];
for (var i = 0; i < grp.length; i++) {
for (var j = i + 1; j < grp.length; j++) {
var ia = participants.indexOf(grp[i]);
var ib = participants.indexOf(grp[j]);
metPairs[pairKey(ia, ib)] = true;
}
}
}
extraRounds.push(extraRound);
}
return {
rounds: rounds,
extraRounds: extraRounds,
groupSize: groupSize,
numGroups: numGroups,
remainder: remainder
};
}
function tryCreateStrictRound(groupSize, numGroups, remainder, metPairs) {
var n = participants.length;
for (var attempt = 0; attempt < 5000; attempt++) {
var indices = shuffledIndices(n);
var groups = [];
var pos = 0;
var valid = true;
for (var g = 0; g < numGroups && valid; g++) {
var size = groupSize + (g < remainder ? 1 : 0);
var group = indices.slice(pos, pos + size);
pos += size;
for (var i = 0; i < group.length && valid; i++) {
for (var j = i + 1; j < group.length && valid; j++) {
if (metPairs[pairKey(group[i], group[j])]) {
valid = false;
}
}
}
if (valid) groups.push(group);
}
if (valid && pos === n && groups.length === numGroups) {
return groups;
}
}
return null;
}
function tryCreateExtraRound(groupSize, numGroups, remainder, globalMet) {
var n = participants.length;
var bestRound = null;
var bestNewPairs = 0;
var bestRepeats = null;
for (var attempt = 0; attempt < 2000; attempt++) {
var indices = shuffledIndices(n);
var groups = [];
var pos = 0;
for (var g = 0; g < numGroups; g++) {
var size = groupSize + (g < remainder ? 1 : 0);
var grp = [];
for (var i = pos; i < pos + size; i++) {
grp.push(participants[indices[i]]);
}
groups.push(grp);
pos += size;
}
var round = { groups: groups, groupSize: groupSize, numGroups: numGroups };
var newPairs = 0;
var repeats = {};
for (var g = 0; g < groups.length; g++) {
for (var i = 0; i < groups[g].length; i++) {
for (var j = i + 1; j < groups[g].length; j++) {
var ia = participants.indexOf(groups[g][i]);
var ib = participants.indexOf(groups[g][j]);
var key = pairKey(ia, ib);
if (!globalMet[key]) {
newPairs++;
} else {
repeats[ia] = true;
repeats[ib] = true;
}
}
}
}
if (newPairs > bestNewPairs) {
bestNewPairs = newPairs;
bestRound = round;
bestRepeats = repeats;
}
if (newPairs > 0 && attempt > 500) break;
}
if (!bestRound || bestNewPairs === 0) return null;
return {
round: bestRound,
newPairs: bestNewPairs,
repeats: bestRepeats
};
}
// ============ STRATEGIE-BUILDER ============
function buildStrategy(type) {
var n = participants.length;
var totalPairs = (n * (n - 1)) / 2;
var preferredSizes = getPreferredSizes(type);
var roundPool = buildRoundPool(preferredSizes);
var beamWidth = 30;
var topK = 8;
var maxDepth = 30;
var initialState = {
path: [],
met: {},
metCount: 0,
used: {},
lastSize: 0,
consecutiveSmall: 0,
sizeHistory: []
};
var beam = [initialState];
var completed = [];
for (var depth = 0; depth < maxDepth; depth++) {
var nextBeam = [];
for (var bi = 0; bi < beam.length; bi++) {
var state = beam[bi];
if (state.metCount >= totalPairs) {
completed.push(state);
continue;
}
var candidates = selectNextRounds(type, state, roundPool, topK);
if (candidates.length === 0) {
var forced = createForcedRound(state.met, state.lastSize, state.consecutiveSmall, type, state.sizeHistory);
if (forced) candidates.push(forced);
}
for (var ci = 0; ci < candidates.length; ci++) {
nextBeam.push(applyCandidateToState(state, candidates[ci]));
}
}
if (nextBeam.length === 0) break;
nextBeam.sort(function(a, b) { return compareStates(a, b, totalPairs, type); });
beam = nextBeam.slice(0, beamWidth);
}
for (var ri = 0; ri < beam.length; ri++) {
if (beam[ri].metCount >= totalPairs) completed.push(beam[ri]);
}
var bestState;
if (completed.length > 0) {
completed.sort(function(a, b) { return compareStates(a, b, totalPairs, type); });
bestState = completed[0];
} else if (beam.length > 0) {
beam.sort(function(a, b) { return compareStates(a, b, totalPairs, type); });
bestState = beam[0];
} else {
bestState = initialState;
}
var path = bestState.path.slice();
var met = {};
for (var pi = 0; pi < path.length; pi++) markPairs(path[pi].round, met);
path = optimizeEnding(path, met, totalPairs);
met = {};
for (var mi = 0; mi < path.length; mi++) markPairs(path[mi].round, met);
var quality = calculateQuality(path, met, totalPairs);
return {
path: path,
totalRounds: path.length,
coverage: quality.coverage,
metPairs: Object.keys(met).length,
totalPairs: totalPairs,
quality: quality
};
}
function getPreferredSizes(type) {
var sizes = [];
for (var gs in configurations) {
if (configurations[gs].rounds.length > 0) {
sizes.push(parseInt(gs));
}
}
if (type === 'efficient') {
sizes.sort(function(a, b) { return b - a; });
} else if (type === 'varied') {
sizes.sort(function(a, b) {
var aMid = Math.abs(a - 4);
var bMid = Math.abs(b - 4);
return aMid - bMid;
});
} else {
var priority = {3: 0, 4: 1, 5: 2, 6: 3, 2: 4, 7: 5, 8: 6};
sizes.sort(function(a, b) {
var pa = priority[a] !== undefined ? priority[a] : 10;
var pb = priority[b] !== undefined ? priority[b] : 10;
return pa - pb;
});
}
return sizes;
}
function buildRoundPool(preferredSizes) {
var pool = [];
var id = 0;
for (var si = 0; si < preferredSizes.length; si++) {
var gs = preferredSizes[si];
var cfg = configurations[gs];
if (!cfg) continue;
var rounds = cfg.rounds || [];
for (var r = 0; r < rounds.length; r++) {
pool.push({
id: id++,
gs: gs,
round: rounds[r],
hasRepeats: false,
repeats: null
});
}
var extras = cfg.extraRounds || [];
for (var e = 0; e < extras.length; e++) {
pool.push({
id: id++,
gs: gs,
round: extras[e].round,
hasRepeats: true,
repeats: extras[e].repeats || null
});
}
}
return pool;
}
function selectNextRounds(type, state, roundPool, topK) {
var candidates = [];
for (var pi = 0; pi < roundPool.length; pi++) {
var item = roundPool[pi];
if (state.used[item.id]) continue;
if (item.gs <= 3 && state.consecutiveSmall >= 2 && item.gs !== 2) continue;
if (item.gs === 2 && state.sizeHistory.length > 3) {
var recent2er = 0;
for (var h = Math.max(0, state.sizeHistory.length - 3); h < state.sizeHistory.length; h++) {
if (state.sizeHistory[h] === 2) recent2er++;
}
if (recent2er >= 1) continue;
}
var newPairs = countNewPairs(item.round, state.met);
if (newPairs > 0) {
var score = calculateRoundScore(type, item.gs, newPairs, state.lastSize, state.sizeHistory, item.hasRepeats);
candidates.push({
id: item.id,
round: item.round,
gs: item.gs,
newPairs: newPairs,
score: score,
hasRepeats: item.hasRepeats,
repeats: item.repeats
});
}
}
candidates.sort(function(a, b) { return b.score - a.score; });
return candidates.slice(0, topK);
}
function calculateRoundScore(type, gs, newPairs, lastSize, history, isForced) {
var score = newPairs * 10;
if (type === 'efficient') {
score = newPairs * 100;
if (gs === 2) score -= 120;
} else if (type === 'varied') {
if (gs !== lastSize) score += 50;
if (history.length >= 1 && history[history.length - 1] === gs) score -= 30;
if (history.length >= 2 && history[history.length - 2] === gs) score -= 15;
if (gs === 3 || gs === 4) score += 20;
if (gs === 5) score += 10;
if (gs === 2) score -= 140;
} else {
if (gs === 3) score += 40;
if (gs === 4) score += 40;
if (gs === 5) score += 20;
if (gs === 2) score -= 180;
if (gs !== lastSize) score += 25;
}
if (isForced) score -= 25;
return score;
}
function applyCandidateToState(state, candidate) {
var metCopy = {};
for (var key in state.met) metCopy[key] = state.met[key];
var gained = markPairs(candidate.round, metCopy);
var path = state.path.slice();
path.push(candidate);
var history = state.sizeHistory.slice();
history.push(candidate.gs);
var used = {};
for (var id in state.used) used[id] = state.used[id];
if (candidate.id !== undefined && candidate.id !== null) used[candidate.id] = true;
return {
path: path,
met: metCopy,
metCount: state.metCount + gained,
used: used,
lastSize: candidate.gs,
consecutiveSmall: candidate.gs <= 3 ? state.consecutiveSmall + 1 : 0,
sizeHistory: history
};
}
function getPathMetrics(path) {
var twoer = 0;
var repeats = 0;
var transitions = 0;
var sizeCount = {};
var maxConsecutive = 1;
var currentConsecutive = 1;
for (var i = 0; i < path.length; i++) {
var gs = path[i].gs;
if (gs === 2) twoer++;
if (path[i].hasRepeats) repeats++;
sizeCount[gs] = (sizeCount[gs] || 0) + 1;
if (i > 0) {
if (path[i - 1].gs !== gs) transitions++;
if (path[i - 1].gs === gs) {
currentConsecutive++;
if (currentConsecutive > maxConsecutive) maxConsecutive = currentConsecutive;
} else {
currentConsecutive = 1;
}
}
}
var uniqueSizes = Object.keys(sizeCount).length;
return {
twoer: twoer,
repeats: repeats,
transitions: transitions,
uniqueSizes: uniqueSizes,
maxConsecutive: path.length > 0 ? maxConsecutive : 0
};
}
function getStyleScore(type, metrics, path) {
if (type === 'efficient') {
var sum = 0;
for (var i = 0; i < path.length; i++) sum += path[i].gs;
return sum;
}
if (type === 'varied') {
return metrics.transitions * 20 + metrics.uniqueSizes * 30 - metrics.maxConsecutive * 10;
}
var mediumCount = 0;
for (var j = 0; j < path.length; j++) {
if (path[j].gs === 3 || path[j].gs === 4) mediumCount++;
}
return mediumCount * 25 + metrics.uniqueSizes * 10 - metrics.maxConsecutive * 15;
}
function compareStates(a, b, totalPairs, type) {
var aComplete = a.metCount >= totalPairs ? 1 : 0;
var bComplete = b.metCount >= totalPairs ? 1 : 0;
if (aComplete !== bComplete) return bComplete - aComplete;
if (a.metCount !== b.metCount) return b.metCount - a.metCount;
if (a.path.length !== b.path.length) return a.path.length - b.path.length;
var am = getPathMetrics(a.path);
var bm = getPathMetrics(b.path);
if (am.twoer !== bm.twoer) return am.twoer - bm.twoer;
if (am.repeats !== bm.repeats) return am.repeats - bm.repeats;
var aStyle = getStyleScore(type, am, a.path);
var bStyle = getStyleScore(type, bm, b.path);
if (aStyle !== bStyle) return bStyle - aStyle;
return 0;
}
function createForcedRound(met, lastSize, consecutiveSmall, type, history) {
var n = participants.length;
var preferredNoTwo = type === 'efficient' ? [6, 5, 4, 3] : [4, 5, 3, 6];
if (consecutiveSmall >= 2) preferredNoTwo = [6, 5, 4, 7, 3];
var phases = [preferredNoTwo, [2]];
for (var phase = 0; phase < phases.length; phase++) {
var preferredSizes = phases[phase];
var best = null;
for (var pi = 0; pi < preferredSizes.length; pi++) {
var gs = preferredSizes[pi];
var numGroups = Math.floor(n / gs);
var remainder = n % gs;
if (numGroups < 2) continue;
var result = tryCreateForcedRoundWithRepeats(gs, numGroups, remainder, met);
if (!result) continue;
var score = calculateRoundScore(type, gs, result.newPairs, lastSize, history || [], true);
score -= result.repeatCount * 5;
var candidate = {
id: null,
round: result.round,
gs: gs,
newPairs: result.newPairs,
score: score,
hasRepeats: true,
repeats: result.repeats
};
if (!best || candidate.score > best.score) best = candidate;
}
if (best) return best;
}
return null;
}
function tryCreateForcedRoundWithRepeats(groupSize, numGroups, remainder, met) {
var n = participants.length;
var bestRound = null;
var bestNewPairs = 0;
var bestRepeats = null;
var bestRepeatCount = 0;
for (var attempt = 0; attempt < 2000; attempt++) {
var indices = shuffledIndices(n);
var groups = [];
var pos = 0;
for (var g = 0; g < numGroups; g++) {
var size = groupSize + (g < remainder ? 1 : 0);
var grp = [];
for (var i = pos; i < pos + size; i++) {
grp.push(participants[indices[i]]);
}
groups.push(grp);
pos += size;
}
var round = { groups: groups, groupSize: groupSize, numGroups: numGroups };
var newPairs = 0;
var repeats = {};
var repeatCount = 0;
for (var g = 0; g < groups.length; g++) {
for (var i = 0; i < groups[g].length; i++) {
for (var j = i + 1; j < groups[g].length; j++) {
var ia = participants.indexOf(groups[g][i]);
var ib = participants.indexOf(groups[g][j]);
var key = pairKey(ia, ib);
if (!met[key]) {
newPairs++;
} else {
repeats[ia] = true;
repeats[ib] = true;
repeatCount++;
}
}
}
}
if (newPairs > bestNewPairs) {
bestNewPairs = newPairs;
bestRound = round;
bestRepeats = repeats;
bestRepeatCount = repeatCount;
}
if (newPairs > 0 && attempt > 500) break;
}
if (!bestRound || bestNewPairs === 0) return null;
return {
round: bestRound,
newPairs: bestNewPairs,
repeats: bestRepeats,
repeatCount: bestRepeatCount
};
}
function optimizeEnding(path, met, totalPairs) {
if (path.length < 3) return path;
var lastThree = path.slice(-3);
var small2erCount = 0;
for (var i = 0; i < lastThree.length; i++) {
if (lastThree[i].gs === 2) small2erCount++;
}
if (small2erCount >= 2) {
for (var i = 0; i < path.length - 3; i++) {
if (path[i].gs >= 4 && !path[i].hasRepeats) {
for (var j = path.length - 1; j >= path.length - 3; j--) {
if (path[j].gs === 2) {
var temp = path[i];
path[i] = path[j];
path[j] = temp;
break;
}
}
break;
}
}
}
return path;
}
function calculateQuality(path, met, totalPairs) {
var coverage = Math.round((Object.keys(met).length / totalPairs) * 100);
var totalRepeats = 0;
for (var i = 0; i < path.length; i++) {
if (path[i].hasRepeats) totalRepeats++;
}
var sizes = [];
for (var i = 0; i < path.length; i++) {
sizes.push(path[i].gs);
}
var uniqueSizes = [];
for (var i = 0; i < sizes.length; i++) {
if (uniqueSizes.indexOf(sizes[i]) === -1) uniqueSizes.push(sizes[i]);
}
var maxConsecutive = 1;
var current = 1;
for (var i = 1; i < sizes.length; i++) {
if (sizes[i] === sizes[i-1]) {
current++;
if (current > maxConsecutive) maxConsecutive = current;
} else {
current = 1;
}
}
var ending2er = 0;
for (var i = Math.max(0, path.length - 3); i < path.length; i++) {
if (path[i].gs === 2) ending2er++;
}
return {
coverage: coverage,
repeatRounds: totalRepeats,
varietyScore: uniqueSizes.length,
maxConsecutive: maxConsecutive,
ending2er: ending2er,
overall: coverage === 100 && totalRepeats <= 2 && maxConsecutive <= 2 && ending2er === 0 ? 'good' :
coverage === 100 && totalRepeats <= 4 && maxConsecutive <= 3 ? 'medium' : 'poor'
};
}
// ============ HILFSFUNKTIONEN ============
function pairKey(a, b) {
return a < b ? a + "-" + b : b + "-" + a;
}
function shuffledIndices(n) {
var arr = [];
for (var i = 0; i < n; i++) arr.push(i);
for (var i = arr.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var t = arr[i]; arr[i] = arr[j]; arr[j] = t;
}
return arr;
}
function indexRoundToNamed(round, groupSize, numGroups) {
var groups = [];
for (var g = 0; g < round.length; g++) {
var names = [];
for (var i = 0; i < round[g].length; i++) {
names.push(participants[round[g][i]]);
}
groups.push(names);
}
return { groups: groups, groupSize: groupSize, numGroups: numGroups };
}
function countNewPairs(round, met) {
var count = 0;
for (var g = 0; g < round.groups.length; g++) {
for (var i = 0; i < round.groups[g].length; i++) {
for (var j = i + 1; j < round.groups[g].length; j++) {
var ia = participants.indexOf(round.groups[g][i]);
var ib = participants.indexOf(round.groups[g][j]);
if (!met[pairKey(ia, ib)]) count++;
}
}
}
return count;
}
function markPairs(round, met) {
var count = 0;
for (var g = 0; g < round.groups.length; g++) {
for (var i = 0; i < round.groups[g].length; i++) {
for (var j = i + 1; j < round.groups[g].length; j++) {
var ia = participants.indexOf(round.groups[g][i]);
var ib = participants.indexOf(round.groups[g][j]);
var key = pairKey(ia, ib);
if (!met[key]) {
met[key] = true;
count++;
}
}
}
}
return count;
}
// matrix[min(i,j)][max(i,j)]: 0 = offen, 1 = geplant, 2 = erledigt (oberes Dreieck)
function initMatrix() {
matrix = {};
var n = participants.length;
for (var i = 0; i < n; i++) {
matrix[i] = {};
for (var j = 0; j < n; j++) matrix[i][j] = 0;
}
}
function groupKey(names) {
return names.slice().sort().join('|');
}
function calcCoverageSingleSize(rounds) {
var n = participants.length;
var total = (n * (n - 1)) / 2;
var met = {};
for (var r = 0; r < rounds.length; r++) {
markPairs(rounds[r], met);
}
return Math.round((Object.keys(met).length / total) * 100);
}
function calcCoverageWithExtra(cfg) {
var n = participants.length;
var total = (n * (n - 1)) / 2;
var met = {};
for (var r = 0; r < cfg.rounds.length; r++) {
markPairs(cfg.rounds[r], met);
}
if (cfg.extraRounds) {
for (var r = 0; r < cfg.extraRounds.length; r++) {
markPairs(cfg.extraRounds[r].round, met);
}
}
return Math.round((Object.keys(met).length / total) * 100);
}
// ============ RENDERING ============
// Begegnungsmatrix: Ecke A1 (↩) ruft onRestoreMatrixToPlan() auf
function renderResults() {
var n = participants.length;
var totalPairs = (n * (n - 1)) / 2;
var h = "";
if (generatorMode !== 'custom') {
var minRounds = Math.min(strategies.efficient.totalRounds, strategies.varied.totalRounds, strategies.balanced.totalRounds);
h += '<div class="container">';
h += '<h2>' + t("overviewTitle", { n: n }) + '</h2>';
h += '<div class="stats">';
h += '<div class="stat-box"><div class="stat-number">' + n + '</div><div class="stat-label">' + t("participants") + '</div></div>';
h += '<div class="stat-box"><div class="stat-number">' + totalPairs + '</div><div class="stat-label">' + t("encountersNeeded") + '</div></div>';
h += '<div class="stat-box"><div class="stat-number">' + Object.keys(configurations).length + '</div><div class="stat-label">' + t("groupSizes") + '</div></div>';
h += '<div class="stat-box"><div class="stat-number">' + minRounds + '</div><div class="stat-label">' + t("minRounds") + '</div></div>';
h += '</div></div>';
}
if (generatorMode === 'custom') {
h += '<div class="container">';
h += '<h2>' + t("customPlanTitle") + '</h2>';
h += '<p class="strategy-description">' + t("customPlanDesc") + '</p>';
h += '<div id="customPlanContent"></div>';
h += '<div class="scenario-actions"><button class="btn-small btn-apply" onclick="onCalculate()">' + t("customRegenerate") + '</button></div>';
h += '</div>';
} else {
h += '<div class="container">';
h += '<h2>' + t("recStrategies") + '</h2>';
h += '<p class="scroll-hint">' + t("strategyHint") + '</p>';
h += '<div class="strategy-tabs">';
h += '<button class="strategy-tab" onclick="switchStrategy(\'efficient\', this)">' + t("strategyEff") + ' (' + strategies.efficient.totalRounds + ')</button>';
h += '<button class="strategy-tab" onclick="switchStrategy(\'varied\', this)">' + t("strategyVar") + ' (' + strategies.varied.totalRounds + ')</button>';
h += '<button class="strategy-tab active" onclick="switchStrategy(\'balanced\', this)">' + t("strategyBal") + ' (' + strategies.balanced.totalRounds + ')</button>';
h += '</div>';
h += '<div id="strategyContent"></div>';
h += '</div>';
h += '<div class="container">';
h += '<h2>' + t("singleGroupSizes") + '</h2>';
h += '<div id="scenariosContent"></div>';
h += '</div>';
}
h += '<div class="container" id="matrixBox">';
h += '<h2>' + t("matrixTitle") + '</h2>';
h += '<div class="matrix-legend">';
h += '<div class="legend-item"><div class="legend-symbol state-off">○</div><span>' + t("open") + '</span></div>';
h += '<div class="legend-item"><div class="legend-symbol state-planned">☑️</div><span>' + t("planned") + '</span></div>';
h += '<div class="legend-item"><div class="legend-symbol state-done">✅</div><span>' + t("done") + '</span></div>';
h += '</div>';
h += '<div id="matrixContent"></div>';
h += '<div id="selectedRoundsContent"></div>';
h += '<div style="margin-top:15px">';
h += '<button class="btn-small btn-clear" onclick="onClearMatrix()">' + t("reset") + '</button>';
h += '<button id="btnMarkAllToggle" class="btn-small btn-mark" onclick="onMarkAllDoneToggle()">' + t("markAllDone") + '</button>';
h += '<button class="btn-small btn-copy" onclick="onCopyText()">' + t("copy") + '</button>';
h += '</div></div>';
document.getElementById("mainResults").innerHTML = h;
if (generatorMode === 'custom') {
renderCustomPlanContent();
renderSelectedRounds();
} else {
renderStrategyContent();
renderScenarios();
}
renderMatrix();
}
function renderCustomPlanContent() {
var el = document.getElementById("customPlanContent");
if (!el) return;
var h = "";
if (customPlanMeta.impossible.length > 0 || customPlanMeta.warnings.length > 0 || (customPlanMeta.conflicts && customPlanMeta.conflicts.length > 0)) {
h += '<div class="config-info warning"><strong>' + t("customIssueSummary") + ':</strong> ';
var notes = [];
if (customPlanMeta.conflicts && customPlanMeta.conflicts.length > 0) {
for (var c = 0; c < customPlanMeta.conflicts.length; c++) {
var conflict = customPlanMeta.conflicts[c];
var analysis = conflict.analysis;
var unitLabel = t(analysis.unit === 'groups' ? 'groupsUnit' : 'personsUnit');
notes.push(t("roundConflictDetail", {
r: conflict.round,
value: analysis.value || 0,
unit: unitLabel,
min: analysis.minAllowed,
max: analysis.maxAllowed,
used: analysis.effectiveValue
}));
}
}
if (customPlanMeta.impossible.length > 0) notes.push('R' + customPlanMeta.impossible.join(', R') + ' ' + t("notFeasible"));
if (customPlanMeta.warnings.length > 0) notes.push('R' + customPlanMeta.warnings.join(', R') + ' ' + t("withRepeatsShort"));
h += notes.join('<br>') + '</div>';
}
for (var i = 0; i < selectedRounds.length; i++) {
h += renderRound(selectedRounds[i], i + 1);
}
el.innerHTML = h;
}
function switchStrategy(type, btn) {
activeStrategy = type;
var tabs = document.querySelectorAll('.strategy-tab');
for (var i = 0; i < tabs.length; i++) {
tabs[i].classList.remove('active');
}
btn.classList.add('active');
renderStrategyContent();
}
function renderStrategyContent() {
var strategy = strategies[activeStrategy];
var n = participants.length;
var totalPairs = (n * (n - 1)) / 2;
var descriptions = {
efficient: t("descEff"),
varied: t("descVar"),
balanced: t("descBal")
};
var h = '<div class="strategy-description">' + descriptions[activeStrategy] + '</div>';
var q = strategy.quality;
h += '<div class="quality-indicator">';
h += '<div class="quality-item"><span class="quality-dot ' + (q.coverage === 100 ? 'good' : 'poor') + '"></span> ' + q.coverage + '% ' + t("coverage") + '</div>';
h += '<div class="quality-item"><span class="quality-dot ' + (q.repeatRounds <= 2 ? 'good' : q.repeatRounds <= 4 ? 'medium' : 'poor') + '"></span> ' + q.repeatRounds + ' ' + t("roundsWithRep") + '</div>';
h += '<div class="quality-item"><span class="quality-dot ' + (q.maxConsecutive <= 2 ? 'good' : 'medium') + '"></span> ' + t("maxSameSize", { n: q.maxConsecutive }) + '</div>';
h += '<div class="quality-item"><span class="quality-dot ' + (q.ending2er === 0 ? 'good' : 'poor') + '"></span> ' + (q.ending2er === 0 ? t("no2end") : t("pairEndCount", { n: q.ending2er })) + '</div>';
h += '</div>';
h += '<div class="config-info ' + (q.overall === 'good' ? 'success' : q.overall === 'medium' ? 'warning' : '') + '">';
h += '<strong>' + strategy.metPairs + '</strong> ' + t("of") + ' ' + totalPairs + ' ' + t("encountersNeeded") + ' ' + t("inRounds") + ' <strong>' + strategy.totalRounds + ' ' + t("rounds") + '</strong>';
if (q.repeatRounds > 0) {
h += ' (' + q.repeatRounds + ' ' + t("withRepeatedEncounters") + ')';
}
h += '</div>';
h += '<div class="path-visual">';
for (var i = 0; i < strategy.path.length; i++) {
if (i > 0) h += '<span class="path-arrow">→</span>';
var item = strategy.path[i];
var stepClass = 'path-step size-' + Math.min(item.gs, 6);
if (item.hasRepeats) stepClass += ' has-repeats';
h += '<span class="' + stepClass + '">' + item.gs + '</span>';
}
h += '</div>';
h += '<div class="collapsible-header" onclick="toggleCollapse(this)">';
h += '<span>' + t("roundsWithNames", { n: strategy.path.length }) + '</span>';
h += '</div>';
h += '<div class="collapsible-content collapsed">';
h += '<p class="click-hint">' + t("clickDoneHint") + '</p>';
for (var i = 0; i < strategy.path.length; i++) {
h += renderRound(strategy.path[i], i + 1);
}
h += '</div>';
h += '<div class="scenario-actions">';
h += '<button class="btn-small btn-apply" onclick="applyStrategy(\'' + activeStrategy + '\', 1)">' + t("asPlanned") + '</button>';
h += '<button class="btn-small btn-apply" onclick="applyStrategy(\'' + activeStrategy + '\', 2)">' + t("asDone") + '</button>';
h += '</div>';
document.getElementById("strategyContent").innerHTML = h;
}
function renderRound(item, num) {
var round = item.round;
var hasRepeats = item.hasRepeats;
var repeats = item.repeats;
var h = '<div class="round' + (hasRepeats ? ' has-repeats' : '') + '">';
h += '<div class="round-header">';
h += '<span>' + t("round") + ' ' + num + (hasRepeats ? ' ⚠️ (' + t("containsRep") + ')' : '') + '</span>';
h += '<span class="round-info">' + round.groups.length + ' ' + t("groupsAbout", { n: item.gs }) + '</span>';
h += '</div>';
for (var g = 0; g < round.groups.length; g++) {
var group = round.groups[g];
var gKey = groupKey(group);
var isCompleted = completedGroups[gKey];
var cls = 'size-' + Math.min(group.length, 6);
if (isCompleted) cls += ' completed';
var nameDisplay = [];
for (var i = 0; i < group.length; i++) {
var name = group[i];
var idx = participants.indexOf(name);
if (hasRepeats && repeats && repeats[idx]) {
nameDisplay.push('<u>' + name + '</u>');
} else {
nameDisplay.push(name);
}
}
var namesJson = JSON.stringify(group).replace(/"/g, '"');
h += '<span class="group ' + cls + '" onclick="markGroupDone(' + namesJson + ')">';
h += nameDisplay.join(', ');
h += '</span>';
}
h += '</div>';
return h;
}
function renderScenarios() {
var h = "";
var sizes = [];
for (var gs in configurations) sizes.push(parseInt(gs));
sizes.sort(function(a, b) { return a - b; });
for (var si = 0; si < sizes.length; si++) {
var gs = sizes[si];
var cfg = configurations[gs];
var strictCoverage = calcCoverageSingleSize(cfg.rounds);
var fullCoverage = calcCoverageWithExtra(cfg);
h += '<div class="scenario-card">';
h += '<div class="scenario-header">';
h += '<span class="scenario-title">' + t("groupsTitle", { n: gs }) + '</span>';
h += '<span>';
if (strictCoverage === 100) {
h += '<span class="scenario-badge">' + t("noRep100") + '</span>';
} else if (fullCoverage === 100) {
h += '<span class="scenario-badge warning">' + t("withRep100") + '</span>';
} else {
h += '<span class="scenario-badge warning">' + t("maxPct", { n: fullCoverage }) + '</span>';
}
h += '</span></div>';
h += '<div class="scenario-stats">';
h += '<span class="scenario-stat">👥 <strong>' + cfg.numGroups + '</strong> ' + t("groupsPerRound") + '</span>';
if (cfg.remainder > 0) {
h += '<span class="scenario-stat">➕ ' + t("withPers", { n: cfg.remainder, m: gs + 1 }) + '</span>';
}
h += '<span class="scenario-stat">🔄 <strong>' + cfg.rounds.length + '</strong> ' + t("strictRounds") + '</span>';
if (cfg.extraRounds && cfg.extraRounds.length > 0) {
h += '<span class="scenario-stat">⚡ <strong>' + cfg.extraRounds.length + '</strong> ' + t("extraRounds") + '</span>';
}
h += '</div>';
// Aufklappbare Liste: strikte Runden
h += '<div class="collapsible-header collapsed" onclick="toggleCollapse(this)">';
h += '<span>' + t("roundsNoRep", { n: cfg.rounds.length, pct: strictCoverage }) + '</span>';
h += '</div>';
h += '<div class="collapsible-content collapsed">';
h += '<p class="click-hint">' + t("clickGroupHint") + '</p>';
for (var r = 0; r < cfg.rounds.length; r++) {
h += renderSimpleRound(cfg.rounds[r], r + 1, gs, false, null);
}
h += '</div>';
// Aufklappbare Liste: Extra-Runden
if (cfg.extraRounds && cfg.extraRounds.length > 0) {
h += '<div class="collapsible-header collapsed" onclick="toggleCollapse(this)">';
h += '<span>' + t("extraFor100", { n: cfg.extraRounds.length }) + '</span>';
h += '</div>';
h += '<div class="collapsible-content collapsed">';
h += '<div class="extra-hint">' + t("extraHint") + '</div>';
for (var r = 0; r < cfg.extraRounds.length; r++) {
var extra = cfg.extraRounds[r];
h += renderSimpleRound(extra.round, cfg.rounds.length + r + 1, gs, true, extra.repeats);
}
h += '</div>';
}
// Schnellauswahl: Runden in den Plan übernehmen
h += '<div class="scenario-actions">';
var maxQuick = Math.min(cfg.rounds.length, 4);
for (var num = 1; num <= maxQuick; num++) {
var cov = calcCoverageSingleSize(cfg.rounds.slice(0, num));
h += '<button class="btn-small btn-apply" onclick="applySingleSize(' + gs + ',' + num + ',false)">' + num + 'R (' + cov + '%)</button>';
}
if (cfg.rounds.length > 4) {
h += '<button class="btn-small btn-apply" onclick="applySingleSize(' + gs + ',' + cfg.rounds.length + ',false)">' + t("all") + ' ' + cfg.rounds.length + '</button>';
}
if (cfg.extraRounds && cfg.extraRounds.length > 0 && strictCoverage < 100) {
h += '<button class="btn-small btn-apply" onclick="applySingleSize(' + gs + ',' + cfg.rounds.length + ',true)">' + t("plusExtra") + '</button>';
}
h += '</div></div>';
}
document.getElementById("scenariosContent").innerHTML = h;
}
function renderSimpleRound(round, num, gs, hasRepeats, repeats) {
var h = '<div class="round' + (hasRepeats ? ' has-repeats' : '') + '">';
h += '<div class="round-header">';
h += '<span>' + t("round") + ' ' + num + (hasRepeats ? ' ⚠️' : '') + '</span>';
h += '<span class="round-info">' + round.groups.length + ' ' + t("groupsWord") + '</span>';
h += '</div>';
for (var g = 0; g < round.groups.length; g++) {
var group = round.groups[g];
var gKey = groupKey(group);
var isCompleted = completedGroups[gKey];
var cls = 'size-' + Math.min(group.length, 6);
if (isCompleted) cls += ' completed';
var nameDisplay = [];
for (var i = 0; i < group.length; i++) {
var name = group[i];
var idx = participants.indexOf(name);
if (hasRepeats && repeats && repeats[idx]) {
nameDisplay.push('<u>' + name + '</u>');
} else {
nameDisplay.push(name);
}
}
var namesJson = JSON.stringify(group).replace(/"/g, '"');
h += '<span class="group ' + cls + '" onclick="markGroupDone(' + namesJson + ')">';
h += nameDisplay.join(', ');
h += '</span>';
}
h += '</div>';
return h;
}
function toggleCollapse(el) {
el.classList.toggle("collapsed");
el.nextElementSibling.classList.toggle("collapsed");
}
function renderMatrix() {
var n = participants.length;
var total = (n * (n - 1)) / 2;
var canRestorePlan = selectedRounds.length > 0;
var cornerCls = 'matrix-corner-reset' + (canRestorePlan ? '' : ' disabled');
var cornerTitle = t('matrixRestorePlan');
var cornerClick = canRestorePlan ? ' onclick="onRestoreMatrixToPlan()"' : '';
var h = '<div class="matrix-container"><table><tr><th class="' + cornerCls + '"' + cornerClick + ' title="' + cornerTitle + '">↩</th>';
for (var i = 0; i < n; i++) {
var nm = participants[i];
h += '<th title="' + nm + '">' + (nm.length > 4 ? nm.substring(0,4) + '.' : nm) + '</th>';
}
h += '</tr>';
for (var i = 0; i < n; i++) {
h += '<tr><td class="name-header">' + participants[i] + '</td>';
for (var j = 0; j < n; j++) {
if (i === j) {
h += '<td class="cell-self">—</td>';
} else {
var mi = i < j ? i : j, mj = i < j ? j : i;
var st = matrix[mi][mj] || 0;
var cls = st === 0 ? 'state-off' : (st === 1 ? 'state-planned' : 'state-done');
var sym = st === 0 ? '○' : (st === 1 ? '☑️' : '✅');
h += '<td class="matrix-cell ' + cls + '" onclick="onCellClick(' + mi + ',' + mj + ')">' + sym + '</td>';
}
}
h += '</tr>';
}
h += '</table></div>';
var stats = getMatrixStats();
var covCls = stats.donePct === 100 ? 'complete' : (stats.donePct >= 75 ? 'high' : (stats.donePct >= 40 ? 'medium' : 'low'));
var barWidth = stats.totalPct === 0 ? 0 : Math.max(stats.totalPct, 5);
h += '<div class="coverage-display ' + covCls + '">' + t("completedPct", { n: stats.donePct }) + '</div>';
h += '<p class="info-text">' + t("doneOfTotal", { done: stats.done, total: total, planned: stats.planned }) + '</p>';
h += '<div class="progress-bar"><div class="progress-fill ' + covCls + '" style="width:' + barWidth + '%">' + stats.totalPct + '%</div></div>';
document.getElementById("matrixContent").innerHTML = h;
updateMarkAllButtonLabel();
}
function getMatrixStats() {
var n = participants.length;
var total = (n * (n - 1)) / 2;
var planned = 0, done = 0;
for (var i = 0; i < n; i++) {
for (var j = i + 1; j < n; j++) {
var st = matrix[i][j] || 0;
if (st === 1) planned++;
if (st === 2) done++;
}
}
return {
planned: planned,
done: done,
plannedPct: Math.round((planned / total) * 100),
donePct: Math.round((done / total) * 100),
totalPct: Math.round(((planned + done) / total) * 100)
};
}
// ============ AKTIONEN ============
// Matrix & Fortschritt:
// onClearMatrix — Vollreset (Plan löschen)
// resetAllProgress / onRestoreMatrixToPlan — Planungsstand aus selectedRounds
// markGroupDone — Toggle pro Gruppe; onMarkAllDoneToggle — Toggle alle geplanten Paare
function onCellClick(i, j) {
matrix[i][j] = (matrix[i][j] + 1) % 3;
renderMatrix();
}
function isPairInSelectedPlan(mi, mj) {
for (var r = 0; r < selectedRounds.length; r++) {
var round = selectedRounds[r].round;
for (var g = 0; g < round.groups.length; g++) {
var grp = round.groups[g];
var hasI = false;
var hasJ = false;
for (var k = 0; k < grp.length; k++) {
var idx = participants.indexOf(grp[k]);
if (idx === mi) hasI = true;
if (idx === mj) hasJ = true;
}
if (hasI && hasJ) return true;
}
}
return false;
}
function isPairInOtherCompletedGroups(mi, mj, excludeGKey) {
var nameA = participants[mi];
var nameB = participants[mj];
for (var key in completedGroups) {
if (!completedGroups[key] || key === excludeGKey) continue;
var names = key.split('|');
if (names.indexOf(nameA) !== -1 && names.indexOf(nameB) !== -1) return true;
}
return false;
}
function markGroupDone(names) {
var gKey = groupKey(names);
if (completedGroups[gKey]) {
delete completedGroups[gKey];
for (var i = 0; i < names.length; i++) {
for (var j = i + 1; j < names.length; j++) {
var ia = participants.indexOf(names[i]);
var ib = participants.indexOf(names[j]);
var mi = ia < ib ? ia : ib;
var mj = ia < ib ? ib : ia;
if (isPairInOtherCompletedGroups(mi, mj, gKey)) continue;
matrix[mi][mj] = isPairInSelectedPlan(mi, mj) ? 1 : 0;
}
}
} else {
completedGroups[gKey] = true;
for (var i = 0; i < names.length; i++) {
for (var j = i + 1; j < names.length; j++) {
var ia = participants.indexOf(names[i]);
var ib = participants.indexOf(names[j]);
var mi = ia < ib ? ia : ib;
var mj = ia < ib ? ib : ia;
matrix[mi][mj] = 2;
}
}
}
renderMatrix();
renderSelectedRounds();
if (generatorMode === 'custom') {
renderCustomPlanContent();
} else {
renderStrategyContent();
renderScenarios();
}
}
function applyStrategy(type, state) {
var strategy = strategies[type];
initMatrix();
completedGroups = {};
selectedRounds = [];
for (var i = 0; i < strategy.path.length; i++) {
var item = strategy.path[i];
applyRoundToMatrix(item.round, state);
selectedRounds.push(item);
if (state === 2) {
for (var g = 0; g < item.round.groups.length; g++) {
completedGroups[groupKey(item.round.groups[g])] = true;
}
}
}
renderMatrix();
renderSelectedRounds();
renderStrategyContent();
renderScenarios();
document.getElementById("matrixBox").scrollIntoView({ behavior: "smooth" });
}
function applySingleSize(gs, numRounds, includeExtra) {
var cfg = configurations[gs];
initMatrix();
completedGroups = {};
selectedRounds = [];
var rounds = cfg.rounds.slice(0, numRounds);
for (var r = 0; r < rounds.length; r++) {
applyRoundToMatrix(rounds[r], 1);
selectedRounds.push({ round: rounds[r], gs: gs, hasRepeats: false, repeats: null });
}
if (includeExtra && cfg.extraRounds) {
for (var r = 0; r < cfg.extraRounds.length; r++) {
var extra = cfg.extraRounds[r];
applyRoundToMatrix(extra.round, 1);
selectedRounds.push({ round: extra.round, gs: gs, hasRepeats: true, repeats: extra.repeats });
}
}
renderMatrix();
renderSelectedRounds();
renderStrategyContent();
renderScenarios();
document.getElementById("matrixBox").scrollIntoView({ behavior: "smooth" });
}
function applyRoundToMatrix(round, state) {
for (var g = 0; g < round.groups.length; g++) {
for (var i = 0; i < round.groups[g].length; i++) {
for (var j = i + 1; j < round.groups[g].length; j++) {
var ia = participants.indexOf(round.groups[g][i]);
var ib = participants.indexOf(round.groups[g][j]);
var mi = ia < ib ? ia : ib;
var mj = ia < ib ? ib : ia;
if (matrix[mi][mj] < state) matrix[mi][mj] = state;
}
}
}
}
function renderSelectedRounds() {
var el = document.getElementById("selectedRoundsContent");
if (!selectedRounds.length) { el.innerHTML = ""; return; }
var h = '<div class="selected-rounds-container"><h3>' + t("currentPlan", { n: selectedRounds.length }) + '</h3>';
h += '<p class="click-hint">' + t("clickGroupSelectedHint") + '</p>';
for (var r = 0; r < selectedRounds.length; r++) {
var item = selectedRounds[r];
var round = item.round;
var hasRepeats = item.hasRepeats;
var repeats = item.repeats;
h += '<div class="round' + (hasRepeats ? ' has-repeats' : '') + '">';
var specInfo = '';
if (item.roundSpec) {
specInfo = ' [' + item.roundSpec.value + ' ' + t(item.roundSpec.unit === 'groups' ? 'groupsUnit' : 'personsUnit') + ']';
}
h += '<div class="round-header"><span>' + t("round") + ' ' + (r+1) + ' (' + item.gs + ')' + specInfo + (hasRepeats ? ' ⚠️' : '') + '</span></div>';
for (var g = 0; g < round.groups.length; g++) {
var group = round.groups[g];
var isCompleted = completedGroups[groupKey(group)];
var cls = 'size-' + Math.min(group.length, 6);
if (isCompleted) cls += ' completed';
var nameDisplay = [];
for (var i = 0; i < group.length; i++) {
var name = group[i];
var idx = participants.indexOf(name);
if (hasRepeats && repeats && repeats[idx]) {
nameDisplay.push('<u>' + name + '</u>');
} else {
nameDisplay.push(name);
}
}
var namesJson = JSON.stringify(group).replace(/"/g, '"');
h += '<span class="group ' + cls + '" onclick="markGroupDone(' + namesJson + ')">' + nameDisplay.join(', ') + '</span>';
}
h += '</div>';
}
h += '</div>';
el.innerHTML = h;
}
function onClearMatrix() {
initMatrix();
completedGroups = {};
selectedRounds = [];
customPlanMeta = { warnings: [], impossible: [], conflicts: [] };
renderMatrix();
renderSelectedRounds();
if (generatorMode === 'custom') {
renderCustomPlanContent();
} else {
renderStrategyContent();
renderScenarios();
}
}
function isAllProgressMarkedDone() {
var n = participants.length;
if (n < 4) return false;
for (var i = 0; i < n; i++) {
for (var j = i + 1; j < n; j++) {
if (matrix[i][j] === 1) return false;
}
}
return getMatrixStats().done > 0;
}
function updateMarkAllButtonLabel() {
var btn = document.getElementById("btnMarkAllToggle");
if (!btn) return;
btn.textContent = isAllProgressMarkedDone() ? t("markAllReset") : t("markAllDone");
}
function resetAllProgress() {
initMatrix();
completedGroups = {};
for (var r = 0; r < selectedRounds.length; r++) {
applyRoundToMatrix(selectedRounds[r].round, 1);
}
}
function onRestoreMatrixToPlan() {
if (!selectedRounds.length) return;
resetAllProgress();
refreshGroupViews();
}
function markAllProgressDone() {
var n = participants.length;
for (var i = 0; i < n; i++) {
for (var j = i + 1; j < n; j++) {
if (matrix[i][j] === 1) matrix[i][j] = 2;
}
}
for (var r = 0; r < selectedRounds.length; r++) {
for (var g = 0; g < selectedRounds[r].round.groups.length; g++) {
completedGroups[groupKey(selectedRounds[r].round.groups[g])] = true;
}
}
}
function refreshGroupViews() {
renderMatrix();
renderSelectedRounds();
if (generatorMode === 'custom') {
renderCustomPlanContent();
} else {
renderStrategyContent();
renderScenarios();
}
}
function onMarkAllDoneToggle() {
if (isAllProgressMarkedDone()) {
resetAllProgress();
} else {
markAllProgressDone();
}
refreshGroupViews();
}
function onCopyText() {
var n = participants.length;
var stats = getMatrixStats();
var total = (n * (n - 1)) / 2;
var txt = "═══════════════════════════════════════════\n";
txt += " " + t("copyHeader") + "\n";
txt += "═══════════════════════════════════════════\n\n";
txt += t("copyParticipants", { n: n }) + "\n";
txt += participants.join(", ") + "\n\n";
txt += t("copyMode", { mode: generatorMode === 'custom' ? t("modeCustom") : t("modeAuto") }) + "\n\n";
if (selectedRounds.length) {
txt += "───────────────────────────────────────────\n";
txt += t("copyPhases", { n: selectedRounds.length }) + "\n";
txt += "───────────────────────────────────────────\n\n";
for (var r = 0; r < selectedRounds.length; r++) {
var item = selectedRounds[r];
txt += t("copyRound", { n: r + 1, gs: item.gs, rep: (item.hasRepeats ? t("copyRepTag") : "") }) + "\n";
for (var g = 0; g < item.round.groups.length; g++) {
txt += " " + t("room") + " " + (g+1) + ": " + item.round.groups[g].join(", ") + "\n";
}
txt += "\n";
}
}
txt += "───────────────────────────────────────────\n";
txt += t("status") + ": " + t("completedPct", { n: stats.donePct }) + " (" + stats.done + "/" + total + ")\n";
txt += "═══════════════════════════════════════════\n";
if (navigator.clipboard) {
navigator.clipboard.writeText(txt).then(function() { alert(t("copied")); });
} else {
var ta = document.createElement("textarea");
ta.value = txt;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
alert(t("copied"));
}
}
updateStaticTexts();
setGeneratorMode('auto');
onNamesInput();
</script>
</body>
</html>