├── data
└── event_durations.json
├── README.md
├── index.html
├── style.css
└── app.js
/data/event_durations.json:
--------------------------------------------------------------------------------
1 | {
2 | "description": "Planned time (in minutes) for each age group and discipline to finish a competition event.",
3 | "entries": [
4 | {
5 | "age_group": "U8",
6 | "discipline": "Schlagwurf",
7 | "time_minutes": 30
8 | },
9 | {
10 | "age_group": "U8",
11 | "discipline": "Biathlon-Staffel",
12 | "time_minutes": 15
13 | },
14 | {
15 | "age_group": "U8",
16 | "discipline": "Hindernissprint-Staffel",
17 | "time_minutes": 15
18 | },
19 | {
20 | "age_group": "U8",
21 | "discipline": "Einbeinhüpfer-Staffel",
22 | "time_minutes": 15
23 | },
24 | {
25 | "age_group": "U10",
26 | "discipline": "Hindernissprint-Staffel",
27 | "time_minutes": 15
28 | },
29 | {
30 | "age_group": "U10",
31 | "discipline": "Biathlon-Staffel",
32 | "time_minutes": 15
33 | },
34 | {
35 | "age_group": "U10",
36 | "discipline": "Weitsprung",
37 | "time_minutes": 30
38 | },
39 | {
40 | "age_group": "U10",
41 | "discipline": "Schlagwurf",
42 | "time_minutes": 30
43 | },
44 | {
45 | "age_group": "U12",
46 | "discipline": "50m Sprint",
47 | "time_minutes": 15
48 | },
49 | {
50 | "age_group": "U12",
51 | "discipline": "6x50m Staffel",
52 | "time_minutes": 15
53 | },
54 | {
55 | "age_group": "U12",
56 | "discipline": "Weitsprung",
57 | "time_minutes": 30
58 | },
59 | {
60 | "age_group": "U12",
61 | "discipline": "Schlagwurf",
62 | "time_minutes": 30
63 | }
64 | ]
65 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Wettbewerb Zeitplaner
2 |
3 | Ein einfacher, rein statischer Planer für Leichtathletik-Wettkämpfe zur Visualisierung von Zeitplänen nach Anlagen und Altersklassen. Implementiert in HTML/CSS/JavaScript (ohne Framework).
4 |
5 | ## Features
6 |
7 | - Eingabe Startzeit und Gesamtdauer -> Erstellung von 5-Minuten-Slots
8 | - Zwei parallele Ansichten:
9 | - Anlagen-Zuteilung (welche Disziplin / Altersklasse auf welcher Anlage zu welcher Zeit)
10 | - Altersklassen-Zeitpläne (eine Zeile pro Altersklasse; Blöcke erscheinen nacheinander über die Zeit)
11 | - Drag & Drop von (Altersklasse + Disziplin)-Elementen in die Anlagenansicht
12 | - Umplanung durch Drag & Drop bereits geplanter Blöcke (Facility-Ansicht)
13 | - Dynamisches Hinzufügen weiterer Anlagen
14 | - Konflikt-Erkennung (Überlappungen) mit Warnung
15 | - Nutzung der Datengrundlage aus `data/event_durations.json`
16 |
17 | ## Nutzung
18 |
19 | 1. Repository lokal öffnen.
20 | 2. Eine einfache statische Auslieferung starten (optional) oder direkt `index.html` im Browser öffnen.
21 | - Unter Windows PowerShell kann z. B. ein temporärer Server mit Python gestartet werden (falls installiert):
22 |
23 | ```powershell
24 | python -m http.server 8000
25 | ```
26 |
27 | 3. Im Browser `http://localhost:8000/` aufrufen (falls Server genutzt), ansonsten per Doppelklick auf `index.html` (Fetch der JSON kann je nach Browser ohne Server blockiert werden – dann Server verwenden).
28 |
29 | ### Bedienung
30 |
31 | 1. Startzeit und Gesamtdauer (in Minuten) einstellen und auf "Zeitachsen neu aufbauen" klicken.
32 | 2. Links aus der Liste einen Eintrag per Drag & Drop auf eine freie Slot-Zelle der gewünschten Anlage ziehen.
33 | 3. Bei Bedarf oben eine neue Anlage hinzufügen.
34 | 4. Die zweite Ansicht zeigt alle Altersklassen (eine Zeile je Altersklasse) mit ihren geplanten Blöcken in zeitlicher Reihenfolge.
35 | 5. Ein geplanter Block kann durch Klick wieder entfernt werden.
36 |
37 | ## Datenformat
38 |
39 | `data/event_durations.json` liefert ein Array von Einträgen:
40 |
41 | ```jsonc
42 | {
43 | "age_group": "U10",
44 | "discipline": "Weitsprung",
45 | "time_minutes": 30
46 | }
47 | ```
48 |
49 | Diese Dauer wird zur Berechnung der benötigten Slot-Länge (Dauer / 5 aufgerundet) verwendet.
50 |
51 | ## Technische Hinweise
52 |
53 | - 1 Slot = 5 Minuten
54 | - Slots werden dynamisch aus Gesamtdauer erzeugt
55 | - Farbgebung: In Anlagen-Ansicht nach Altersklasse, in Altersklassen-Ansicht nach Anlage
56 | - Overlaps werden erkannt per Intervallvergleich und Block visuell markiert (roter Rahmen), wenn bestätigt
57 | - Keine Persistenz (Seite neu laden setzt Planung zurück)
58 |
59 | ## Mögliche Erweiterungen
60 |
61 | 1. Persistenz (LocalStorage oder Export/Import JSON)
62 | 2. Editierbare Block-Dauern / Verschieben per Drag innerhalb der Timeline
63 | 3. (Erledigt) Alle Altersklassen gleichzeitig sichtbar – optional Umschalt-/Filterfunktion ergänzen
64 | 4. PDF / Bild Export
65 | 5. Filter / Suche nach Disziplin
66 | 6. Validierung: Verhindern, dass eine Altersklasse gleichzeitig zwei Disziplinen hat
67 | 7. Zusammenfassung / automatischer Konflikt-Report
68 | 8. Farbkonfiguration je Disziplin
69 | 9. Mehrsprachigkeit (DE/EN)
70 | 10. Accessiblity-Verbesserungen (ARIA für Drag & Drop)
71 |
72 | ## Lizenz
73 |
74 | Interne Verwendung / bitte nach Bedarf ergänzen.
75 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Wettbewerb Zeitplaner
7 |
8 |
9 |
10 |
41 |
42 |
43 |
44 |
45 |
Riegen je Altersklasse
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
Anlagen-Zuteilung
55 |
56 |
57 |
58 |
Altersklassen-Zeitpläne
59 |
60 |
61 |
62 |
63 |
64 |
65 |
68 |
69 |
70 |
74 |
75 |
76 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/style.css:
--------------------------------------------------------------------------------
1 | * { box-sizing: border-box; }
2 | html, body { height:100%; }
3 | body { font-family: system-ui, Arial, sans-serif; margin: 0; background:#f6f7fb; color:#222; display: flex; flex-direction: column;}
4 | header { background:#23395d; color:#fff; padding:1rem; }
5 | h1 { margin:0 0 .5rem; font-size:1.4rem; }
6 | /* Shared controls row styling for header rows */
7 | .controls-row { display:flex; gap:.4rem; flex-wrap:wrap; align-items:flex-end; margin-bottom:.5rem; }
8 | header .plan-controls select { padding:.35rem .5rem; border-radius:4px; border:1px solid #999; font-size:.75rem; }
9 | header .plan-controls button { margin-top:0; font-size:.7rem; padding:.4rem .6rem; }
10 | header .plan-controls .plan-name { font-size:.75rem; font-weight:600; min-width:120px; display:inline-block; padding:.25rem .4rem; background:#1b2d47; border-radius:4px; }
11 | header .plan-controls .plan-name.dirty::after { content:'*'; color:#ffca28; margin-left:4px; }
12 | header .plan-controls button.danger { background:#c62828; color:#fff; }
13 | header .plan-controls button.danger:hover { background:#e53935; }
14 | header label { font-size:.85rem; display:flex; flex-direction:column; gap:.25rem; color:#eee; }
15 | header input, header select { padding:.35rem .5rem; border-radius:4px; border:1px solid #999; }
16 | header button { margin-top:1.25rem; padding:.5rem .9rem; background:#ff9800; border:none; color:#222; font-weight:600; border-radius:4px; cursor:pointer; font-family: system-ui, Arial, sans-serif;}
17 | header button:hover { background:#ffc24d; }
18 | /* Make the file upload label look like the other header buttons */
19 | .plan-controls .file-upload-button { display:inline-block; padding:.4rem .6rem; background:#ff9800; border:none; color:#222; font-weight:600; border-radius:4px; cursor:pointer; font-size:.7rem; text-align:center; }
20 | .plan-controls .file-upload-button:hover { background:#ffc24d; }
21 | .facility-add-row input { flex:1; padding:.45rem .6rem; border-radius:4px; border:1px solid #999; font-size:.9rem; }
22 |
23 | .views-wrapper { overflow:auto; }
24 | main { display:flex; align-items:stretch; flex: 1; overflow: hidden;}
25 | .unassigned-panel { width:300px; padding:1rem; background:#fff; border-right:1px solid #ddd; display:flex; flex-direction:column; gap:1rem; max-height: calc(100vh - 180px); }
26 | .item-list { display:flex; flex-direction:column; gap:.4rem; overflow:auto; padding-right:.25rem; flex:1 1 auto; min-height:0; }
27 | .draggable-item { background:#fff; border:1px solid #bbb; border-left:6px solid transparent; padding:.4rem .5rem; border-radius:4px; cursor:grab; font-size:.8rem; display:flex; justify-content:space-between; align-items:center; gap:.5rem; }
28 | .draggable-item[data-duration="15"] { border-left-color:#4caf50; }
29 | .draggable-item[data-duration="30"] { border-left-color:#2196f3; }
30 | .draggable-item .tag { font-size:.65rem; background:#eee; padding:.1rem .35rem; border-radius:3px; }
31 |
32 | .views-wrapper { flex:1; overflow:auto; }
33 | .views-container { display:flex; flex-wrap:wrap; gap:1rem; padding:1rem; }
34 | .view { background:#fff; border:1px solid #ddd; border-radius:6px; padding:.75rem; min-width:420px; display:flex; flex-direction:column; }
35 | .view h2 { margin:.1rem 0 .75rem; font-size:1rem; }
36 |
37 | .timeline-container { overflow:auto; border:1px solid #ccc; border-radius:4px; background:#fafafa; }
38 | .timeline-header-row { display:grid; position:sticky; top:0; color:#fff; font-size:.65rem; z-index:2; }
39 | .timeline-header-row div { padding:.25rem .3rem; text-align:center; background:#23395d; border-left:1px solid rgba(255,255,255,0.15); }
40 | .timeline-header-row .header-spacer { border-left:none; text-align:left; }
41 |
42 | .facility-row { display:grid; grid-template-columns:140px 1fr; font-size:.7rem; }
43 | .facility-label { padding:.4rem .5rem; font-weight:600; position:sticky; left:0; background:inherit; z-index:200; }
44 | .facility-label .facility-delete { margin-left:.6rem; background:transparent; border:none; color:inherit; font-weight:700; cursor:pointer; padding:0 .25rem; }
45 | .facility-label .facility-delete:hover { color:#c62828; }
46 | .facility-label .facility-rename { margin-left:.2rem; background:transparent; border:none; color:inherit; font-weight:700; cursor:pointer; padding:0 .25rem; }
47 | .facility-label .facility-rename:hover { color:#ffd54f; }
48 | .facility-slots { display:grid; }
49 |
50 | .slot { border-left:1px solid #e0e0e0; min-height:40px; position:relative; }
51 |
52 | .event-block { position:absolute; top:2px; bottom:2px; left:2px; background:#2196f3; color:#fff; border-radius:4px; padding:2px 4px; font-size:.6rem; display:flex; flex-direction:column; gap:2px; cursor:pointer; box-shadow:0 1px 3px rgba(0,0,0,.25); z-index: 100; }
53 | .event-block.conflict { outline:2px solid #f44336; }
54 | .event-block .small { font-size:.55rem; opacity:.9; }
55 | .event-block.dragging { opacity: .6; pointer-events: none; }
56 |
57 | .legend { display:flex; flex-wrap:wrap; gap:.5rem; font-size:.65rem; }
58 | .legend-item { display:flex; align-items:center; gap:.3rem; }
59 | .color-box { width:14px; height:14px; border-radius:3px; display:inline-block; }
60 |
61 | /* Squad configuration */
62 | .squad-config { border:1px solid #ddd; padding:.5rem; border-radius:4px; background:#fafafa; max-height:160px; }
63 | .squad-config h3 { margin:.1rem 0 .4rem; font-size:.75rem; text-transform:uppercase; letter-spacing:.5px; }
64 | .squad-config-list { display:flex; flex-direction:column; gap:.35rem; }
65 | .squad-config-row { display:flex; justify-content:space-between; align-items:center; font-size:.65rem; }
66 | .squad-config-row label { flex:1; }
67 | .squad-config-row input { width:60px; padding:.2rem .3rem; font-size:.65rem; }
68 |
69 | /* Age group view */
70 | .age-group-row { display:grid; grid-template-columns:140px 1fr; font-size:.7rem; }
71 | .age-group-row .slot, .facility-row .slot { background:#fff; border-top:1px solid #ddd; }
72 | .age-group-row:nth-child(odd) .slot, .facility-row:nth-child(odd) .slot { background:#f5f7fa; }
73 | .age-group-label { padding:.4rem .5rem; font-weight:600; position:sticky; left:0; background:inherit; z-index:200; }
74 | .age-group-slots { display:grid; }
75 |
76 | @media (max-width: 1100px) {
77 | .unassigned-panel { width:250px; }
78 | .view { min-width:100%; }
79 | }
80 |
81 | @media (max-width: 700px) {
82 | header { padding:.75rem; }
83 | .config-row { flex-direction:column; align-items:stretch; }
84 | .facility-row, .age-group-row { grid-template-columns:120px 1fr; }
85 | .slot { min-height:34px; }
86 | }
87 |
--------------------------------------------------------------------------------
/app.js:
--------------------------------------------------------------------------------
1 | // Zeitplaner Logik
2 | // Datenmodell:
3 | // facilities: [{id, name}]
4 | // events: seed from data/event_durations.json => [{id, age_group, discipline, duration, scheduled?: {...}}]
5 | // scheduled: event.scheduled = { facilityId, startSlot, slotLength }
6 |
7 | const SLOT_MINUTES = 5;
8 |
9 | const state = {
10 | facilities: [],
11 | events: [],
12 | startTime: '09:00',
13 | totalMinutes: 180,
14 | slots: 0,
15 | ageGroups: new Set(),
16 | ageGroupColors: {},
17 | facilityColors: {},
18 | baseEntries: [], // original entries (no squads)
19 | ageGroupSquads: {}, // map age_group -> number of squads (>=1)
20 | currentPlanName: null
21 | };
22 | let dirty = false; // unsaved changes flag
23 | function setDirty(val = true) {
24 | dirty = val;
25 | const nameSpan = document.getElementById('currentPlanName');
26 | if (nameSpan) {
27 | if (dirty) nameSpan.classList.add('dirty'); else nameSpan.classList.remove('dirty');
28 | }
29 | }
30 |
31 | // Utility functions
32 | function parseTimeToMinutes(t) {
33 | const [h, m] = t.split(':').map(Number);
34 | return h * 60 + m;
35 | }
36 | function minutesToTime(minTotal) {
37 | const h = Math.floor(minTotal / 60);
38 | const m = minTotal % 60;
39 | return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`;
40 | }
41 |
42 | function buildSlots() {
43 | state.slots = Math.ceil(state.totalMinutes / SLOT_MINUTES);
44 | }
45 |
46 | function generateId() {
47 | return Math.random().toString(36).slice(2,9);
48 | }
49 |
50 | async function loadData() {
51 | try {
52 | const res = await fetch('data/event_durations.json', { cache: 'no-store' });
53 | if (!res.ok) throw new Error(res.status + ' ' + res.statusText);
54 | const data = await res.json();
55 | const entries = data.entries || [];
56 | state.baseEntries = entries.map(e => ({
57 | age_group: e.age_group,
58 | discipline: e.discipline,
59 | duration: e.time_minutes
60 | }));
61 | regenerateEventsFromSquads();
62 | state.ageGroups = new Set(state.baseEntries.map(e => e.age_group));
63 | assignAgeGroupColors();
64 | assignFacilityColors();
65 | buildUnassignedList();
66 | rebuildTimelines();
67 | setupSquadConfigUI();
68 | } catch (err) {
69 | console.error('Fehler beim Laden der Daten', err);
70 | const list = document.getElementById('unassignedList');
71 | list.innerHTML = `Daten konnten nicht geladen werden: ${err.message}
`;
72 | }
73 | }
74 |
75 | // Removed age group select; all age groups shown simultaneously
76 |
77 | function buildUnassignedList() {
78 | const list = document.getElementById('unassignedList');
79 | list.innerHTML = '';
80 | const frag = document.createDocumentFragment();
81 | state.events.filter(e => !e.scheduled).forEach(evt => {
82 | const div = document.createElement('div');
83 | div.className = 'draggable-item';
84 | div.draggable = true;
85 | div.dataset.eventId = evt.id;
86 | div.dataset.duration = evt.duration;
87 | const baseColor = state.ageGroupColors[evt.base_age_group || evt.age_group] || '#607d8b';
88 | const color = shadeForSquad(baseColor, evt.squadIndex || 0);
89 | div.style.borderLeftColor = color;
90 | const labelGroup = evt.displayGroup || evt.age_group;
91 | div.innerHTML = `${labelGroup} – ${evt.discipline}${evt.duration}'`;
92 | div.addEventListener('dragstart', onDragStartEvent);
93 | frag.appendChild(div);
94 | });
95 | if (!frag.childElementCount) {
96 | const empty = document.createElement('div');
97 | empty.textContent = 'Alle Elemente geplant';
98 | empty.style.fontSize = '.75rem';
99 | empty.style.opacity = '.7';
100 | list.appendChild(empty);
101 | } else {
102 | list.appendChild(frag);
103 | }
104 | }
105 |
106 | function rebuildTimelines() {
107 | buildSlots();
108 | assignFacilityColors(); // ensure new facilities get colors
109 | renderFacilityTimeline();
110 | renderAgeGroupTimeline();
111 | setupScrollSync();
112 | }
113 |
114 | // Horizontal scroll sync for the two timeline containers
115 | let scrollSyncInitialized = false;
116 | let isSyncingScroll = false;
117 | function setupScrollSync() {
118 | if (scrollSyncInitialized) return;
119 | const a = document.getElementById('facilityTimeline');
120 | const b = document.getElementById('ageGroupTimeline');
121 | if (!a || !b) return;
122 | scrollSyncInitialized = true;
123 | // keep initial alignment
124 | b.scrollLeft = a.scrollLeft;
125 | a.addEventListener('scroll', () => {
126 | if (isSyncingScroll) return;
127 | isSyncingScroll = true;
128 | b.scrollLeft = a.scrollLeft;
129 | requestAnimationFrame(() => { isSyncingScroll = false; });
130 | });
131 | b.addEventListener('scroll', () => {
132 | if (isSyncingScroll) return;
133 | isSyncingScroll = true;
134 | a.scrollLeft = b.scrollLeft;
135 | requestAnimationFrame(() => { isSyncingScroll = false; });
136 | });
137 | }
138 |
139 | // Color assignment
140 | // 16 distinctive colors
141 | const PALETTE = [
142 | '#1f77b4','#ff7f0e','#2ca02c','#d62728','#9467bd','#8c564b',
143 | '#e377c2','#7f7f7f','#bcbd22','#17becf','#005f73','#ee9b00',
144 | '#94d2bd','#ff006e','#8338ec','#3a86ff'
145 | ];
146 | // Secondary palette specifically for facilities to improve distinction
147 | const FACILITY_PALETTE = [
148 | '#264653', '#2a9d8f', '#e9c46a', '#f4a261', '#e76f51', '#8d99ae', '#6a4c93', '#ffb703',
149 | '#023047', '#219ebc', '#fb8500', '#9b2226', '#6d6875', '#00b4d8', '#007f5f', '#b5179e'
150 | ];
151 | function assignAgeGroupColors() {
152 | let existing = Object.keys(state.ageGroupColors).length;
153 | [...state.ageGroups].sort().forEach(g => {
154 | if (!state.ageGroupColors[g]) {
155 | state.ageGroupColors[g] = PALETTE[existing % PALETTE.length];
156 | existing++;
157 | }
158 | });
159 | }
160 | function assignFacilityColors() {
161 | let existing = Object.keys(state.facilityColors).length;
162 | state.facilities.forEach(f => {
163 | if (!state.facilityColors[f.id]) {
164 | state.facilityColors[f.id] = FACILITY_PALETTE[existing % FACILITY_PALETTE.length];
165 | existing++;
166 | }
167 | });
168 | }
169 |
170 | function createHeaderGrid(container, slots) {
171 | container.innerHTML = '';
172 | // spacer for label column alignment (140px)
173 | const spacer = document.createElement('div');
174 | spacer.className = 'header-spacer';
175 | container.appendChild(spacer);
176 | const startMinutes = parseTimeToMinutes(state.startTime);
177 | for (let i=0; i 0.6 ? '#000' : '#fff';
194 | }
195 |
196 | // Compare age group labels numerically when possible (e.g. U8 before U10)
197 | function compareAgeGroupLabels(a, b) {
198 | if (a === b) return 0;
199 | // Try to extract a numeric portion (e.g. U8 -> 8)
200 | const re = /(?:U)?(\d+)/i;
201 | const ma = a && a.toString().match(re);
202 | const mb = b && b.toString().match(re);
203 | if (ma && mb) {
204 | const na = parseInt(ma[1], 10);
205 | const nb = parseInt(mb[1], 10);
206 | if (!isNaN(na) && !isNaN(nb)) return na - nb;
207 | }
208 | // If no numeric comparison possible, fallback to natural locale compare
209 | return a.toString().localeCompare(b.toString(), undefined, { numeric: true, sensitivity: 'base' });
210 | }
211 |
212 | function renderFacilityTimeline() {
213 | const wrap = document.getElementById('facilityTimeline');
214 | wrap.innerHTML = '';
215 | const header = document.createElement('div');
216 | header.className = 'timeline-header-row';
217 | createHeaderGrid(header, state.slots);
218 | wrap.appendChild(header);
219 |
220 | const facilities = state.facilities;
221 | if (!facilities.length) {
222 | const hint = document.createElement('div');
223 | hint.style.padding = '0.75rem';
224 | hint.style.fontSize = '.75rem';
225 | hint.textContent = 'Noch keine Anlagen. Fügen Sie eine Anlage hinzu.';
226 | wrap.appendChild(hint);
227 | }
228 |
229 | facilities.forEach(fac => {
230 | const row = document.getElementById('facilityRowTemplate').content.firstElementChild.cloneNode(true);
231 | const labelEl = row.querySelector('.facility-label');
232 | labelEl.textContent = fac.name;
233 | // add rename and delete buttons next to facility label
234 | const renameBtn = document.createElement('button');
235 | renameBtn.className = 'facility-rename';
236 | renameBtn.title = 'Anlage umbenennen';
237 | renameBtn.textContent = '✎';
238 | renameBtn.addEventListener('click', (ev) => {
239 | ev.stopPropagation();
240 | const newName = prompt('Neuer Anlagenname:', fac.name);
241 | if (!newName) return;
242 | const trimmed = newName.trim();
243 | if (!trimmed) return alert('Ungültiger Name');
244 | // update facility name
245 | const idx = state.facilities.findIndex(f => f.id === fac.id);
246 | if (idx >= 0) state.facilities[idx].name = trimmed;
247 | // also update label text immediately
248 | labelEl.firstChild && labelEl.firstChild.nodeType === Node.TEXT_NODE ? labelEl.firstChild.nodeValue = trimmed : labelEl.textContent = trimmed;
249 | setDirty();
250 | rebuildTimelines();
251 | });
252 | // delete button
253 | const delBtn = document.createElement('button');
254 | delBtn.className = 'facility-delete';
255 | delBtn.title = 'Anlage entfernen';
256 | delBtn.textContent = '✕';
257 | delBtn.addEventListener('click', (ev) => {
258 | ev.stopPropagation();
259 | if (!confirm(`Anlage "${fac.name}" wirklich entfernen? Alle zugewiesenen Termine werden freigegeben.`)) return;
260 | // remove facility
261 | state.facilities = state.facilities.filter(f => f.id !== fac.id);
262 | // unassign any events scheduled on this facility
263 | state.events.forEach(e => {
264 | if (e.scheduled && e.scheduled.facilityId === fac.id) e.scheduled = null;
265 | });
266 | setDirty();
267 | buildUnassignedList();
268 | rebuildTimelines();
269 | });
270 | // append rename first so it appears to the left of delete
271 | labelEl.appendChild(renameBtn);
272 | labelEl.appendChild(delBtn);
273 | const fColor = state.facilityColors[fac.id];
274 | if (fColor) {
275 | labelEl.style.background = fColor;
276 | labelEl.style.color = getContrast(fColor);
277 | }
278 | const slotsContainer = row.querySelector('.facility-slots');
279 | slotsContainer.style.gridTemplateColumns = `repeat(${state.slots}, 36px)`;
280 | for (let i=0; i e.scheduled).forEach(placeEventBlockInFacilityView);
296 | }
297 |
298 | function placeEventBlockInFacilityView(evt) {
299 | const { facilityId, startSlot, slotLength } = evt.scheduled;
300 | const selector = `.slot[data-facility-id="${facilityId}"][data-slot-index="${startSlot}"]`;
301 | const startSlotEl = document.querySelector(selector);
302 | if (!startSlotEl) return;
303 | const block = document.createElement('div');
304 | block.className = `event-block`;
305 | block.dataset.eventId = evt.id;
306 | block.draggable = true;
307 | block.style.gridColumn = 'span ' + slotLength; // fallback if we used grid
308 | // Expand across slots by absolutely positioning within first slot, width calculation:
309 | block.style.width = (slotLength * 36 - 6) + 'px';
310 | const displayGroup = evt.displayGroup || evt.age_group;
311 | block.innerHTML = `${displayGroup}
${evt.discipline}
`;
312 | block.title = `${displayGroup} – ${evt.discipline} (${evt.duration} Min)\nKlicken zum Entfernen`;
313 | // Color by squad shade of age group
314 | const baseColor = state.ageGroupColors[evt.base_age_group || evt.age_group];
315 | const shade = shadeForSquad(baseColor, evt.squadIndex || 0);
316 | block.style.background = shade || '#2196f3';
317 | block.style.color = getContrast(block.style.background);
318 | block.addEventListener('click', () => {
319 | if (confirm('Planung entfernen?')) {
320 | evt.scheduled = null;
321 | rebuildTimelines();
322 | buildUnassignedList();
323 | setDirty();
324 | }
325 | });
326 | block.addEventListener('dragstart', onDragStartScheduledEvent);
327 |
328 | // Mark conflicts
329 | if (detectConflict(evt)) block.classList.add('conflict');
330 |
331 | startSlotEl.appendChild(block);
332 | }
333 |
334 | function renderAgeGroupTimeline() {
335 | const wrap = document.getElementById('ageGroupTimeline');
336 | wrap.innerHTML = '';
337 | const header = document.createElement('div');
338 | header.className = 'timeline-header-row';
339 | createHeaderGrid(header, state.slots);
340 | wrap.appendChild(header);
341 |
342 | const sortedGroups = [...state.ageGroups].sort(compareAgeGroupLabels);
343 | if (!sortedGroups.length) {
344 | const msg = document.createElement('div');
345 | msg.style.padding = '0.75rem';
346 | msg.style.fontSize = '.75rem';
347 | msg.textContent = 'Keine Altersklassen vorhanden.';
348 | wrap.appendChild(msg);
349 | return;
350 | }
351 |
352 | sortedGroups.forEach(group => {
353 | const squadCount = state.ageGroupSquads[group] || 1;
354 | for (let s = 0; s < squadCount; s++) {
355 | const groupKey = squadCount > 1 ? `${group} R${s+1}` : group;
356 | const groupEvents = state.events.filter(e => e.base_age_group === group && e.squadIndex === s && e.scheduled)
357 | .sort((a,b) => a.scheduled.startSlot - b.scheduled.startSlot);
358 | const row = document.createElement('div');
359 | row.className = 'age-group-row';
360 | const label = document.createElement('div');
361 | label.className = 'age-group-label';
362 | label.textContent = groupKey;
363 | const baseColor = state.ageGroupColors[group];
364 | if (baseColor) {
365 | const shade = shadeForSquad(baseColor, s);
366 | label.style.background = shade;
367 | label.style.color = getContrast(shade);
368 | }
369 | const slotsContainer = document.createElement('div');
370 | slotsContainer.className = 'age-group-slots';
371 | slotsContainer.style.gridTemplateColumns = `repeat(${state.slots}, 36px)`;
372 | for (let i=0; i {
382 | const startSlotEl = slotsContainer.children[evt.scheduled.startSlot];
383 | if (!startSlotEl) return;
384 | const block = document.createElement('div');
385 | block.className = `event-block`;
386 | const { slotLength, facilityId } = evt.scheduled;
387 | block.style.width = (slotLength * 36 - 6) + 'px';
388 | const facilityName = state.facilities.find(f => f.id === facilityId)?.name || '';
389 | const startMinutes = parseTimeToMinutes(state.startTime) + evt.scheduled.startSlot * SLOT_MINUTES;
390 | const endMinutes = startMinutes + evt.duration;
391 | block.innerHTML = `${minutesToTime(startMinutes)}–${minutesToTime(endMinutes)}
${evt.discipline} • ${facilityName}
`;
392 | block.title = `${evt.discipline} @ ${facilityName}`;
393 | block.style.background = state.facilityColors[facilityId] || '#607d8b';
394 | block.style.color = getContrast(block.style.background);
395 | if (detectAgeGroupConflict(evt)) block.classList.add('conflict');
396 | startSlotEl.appendChild(block);
397 | });
398 |
399 | if (!groupEvents.length) {
400 | const emptyNote = document.createElement('div');
401 | emptyNote.style.position = 'absolute';
402 | emptyNote.style.left = '160px';
403 | emptyNote.style.fontSize = '.6rem';
404 | emptyNote.style.top = '8px';
405 | emptyNote.style.opacity = '.6';
406 | emptyNote.textContent = 'Keine geplanten Disziplinen';
407 | row.style.position = 'relative';
408 | row.appendChild(emptyNote);
409 | }
410 | }
411 | });
412 | }
413 |
414 | function detectConflict(evt) {
415 | if (!evt.scheduled) return false;
416 | const { facilityId, startSlot, slotLength } = evt.scheduled;
417 | // Facility overlap
418 | return state.events.some(other => {
419 | if (other === evt || !other.scheduled) return false;
420 | if (other.scheduled.facilityId !== facilityId) return false;
421 | const a1 = startSlot; const a2 = startSlot + slotLength - 1;
422 | const b1 = other.scheduled.startSlot; const b2 = b1 + other.scheduled.slotLength - 1;
423 | return Math.max(a1,b1) <= Math.min(a2,b2);
424 | });
425 | }
426 |
427 | function detectAgeGroupConflict(evt) {
428 | if (!evt.scheduled) return false;
429 | const { startSlot, slotLength } = evt.scheduled;
430 | const a1 = startSlot; const a2 = startSlot + slotLength - 1;
431 | return state.events.some(other => {
432 | if (other === evt || !other.scheduled) return false;
433 | // Only conflict if same squad OR squad differentiation not present
434 | if (other.age_group !== evt.age_group) return false;
435 | if (other.squadIndex != null && evt.squadIndex != null && other.squadIndex !== evt.squadIndex) return false;
436 | const b1 = other.scheduled.startSlot; const b2 = b1 + other.scheduled.slotLength - 1;
437 | return Math.max(a1,b1) <= Math.min(a2,b2);
438 | });
439 | }
440 |
441 | // Drag & Drop
442 | let draggedEventId = null;
443 | let draggedScheduled = false;
444 | let dragOffsetSlots = 0;
445 | let ghostEl = null;
446 | let draggedElRef = null;
447 | function onDragStartEvent(e) {
448 | draggedEventId = e.currentTarget.dataset.eventId;
449 | e.dataTransfer.effectAllowed = 'move';
450 | e.dataTransfer.setData('text/plain', draggedEventId);
451 | // visually mark and allow pointer events to pass through to slots
452 | draggedElRef = e.currentTarget;
453 | draggedElRef.classList.add('dragging');
454 | dragOffsetSlots = 0; // unassigned items snap to hovered slot
455 | draggedElRef.addEventListener('dragend', onDragEnd);
456 | }
457 | function onDragStartScheduledEvent(e) {
458 | draggedEventId = e.currentTarget.dataset.eventId;
459 | draggedScheduled = true;
460 | e.dataTransfer.effectAllowed = 'move';
461 | e.dataTransfer.setData('text/plain', draggedEventId);
462 | // mark the scheduled event so it doesn't block slot hover while dragging
463 | draggedElRef = e.currentTarget;
464 | // compute grab offset in slots so placement remains relative to cursor
465 | try {
466 | const evt = state.events.find(ev => ev.id === draggedEventId);
467 | const rect = draggedElRef.getBoundingClientRect();
468 | const offsetPixels = (typeof e.clientX === 'number') ? (e.clientX - rect.left) : 0;
469 | const slotW = 36;
470 | const estOffset = Math.floor(offsetPixels / slotW);
471 | const slotLength = evt && evt.scheduled ? evt.scheduled.slotLength : Math.ceil((evt && evt.duration) / SLOT_MINUTES);
472 | dragOffsetSlots = Math.min(Math.max(0, estOffset), Math.max(0, slotLength - 1));
473 | } catch (err) {
474 | dragOffsetSlots = 0;
475 | }
476 | // adding class after a tick helps avoid interfering with native dragstart
477 | setTimeout(() => draggedElRef && draggedElRef.classList.add('dragging'), 0);
478 | draggedElRef.addEventListener('dragend', onDragEnd);
479 | // hide native drag image so only the in-page ghost is visible while dragging inside the plan
480 | try {
481 | const img = new Image();
482 | e.dataTransfer.setDragImage(img, 0, 0);
483 | } catch (_) {}
484 | }
485 |
486 | function onDragEnd(e) {
487 | const el = draggedElRef || e.currentTarget;
488 | if (el) {
489 | el.classList.remove('dragging');
490 | try { el.removeEventListener('dragend', onDragEnd); } catch(_) {}
491 | }
492 | // cleanup drag state
493 | draggedElRef = null;
494 | draggedEventId = null;
495 | draggedScheduled = false;
496 | dragOffsetSlots = 0;
497 | removeGhost();
498 | }
499 | function onSlotDragEnter(e) {
500 | e.preventDefault();
501 | e.currentTarget.classList.add('drop-target');
502 | showGhost(e.currentTarget, e);
503 | }
504 | function onSlotDragOver(e) {
505 | e.preventDefault();
506 | e.dataTransfer.dropEffect = 'move';
507 | showGhost(e.currentTarget, e);
508 | }
509 | function onSlotDragLeave(e) {
510 | e.currentTarget.classList.remove('drop-target');
511 | removeGhost();
512 | }
513 | function onSlotDrop(e) {
514 | e.preventDefault();
515 | const slotEl = e.currentTarget;
516 | slotEl.classList.remove('drop-target');
517 | const eventId = draggedEventId || e.dataTransfer.getData('text/plain');
518 | const evt = state.events.find(ev => ev.id === eventId);
519 | if (!evt) return;
520 | const facilityId = slotEl.dataset.facilityId;
521 | const rawSlotIdx = parseInt(slotEl.dataset.slotIndex,10);
522 | // adjust by the grabbed offset so placement stays relative to cursor
523 | let startSlotIdx = rawSlotIdx - dragOffsetSlots;
524 | if (startSlotIdx < 0) startSlotIdx = 0;
525 | if (startSlotIdx > state.slots) startSlotIdx = state.slots;
526 | const slotLength = Math.ceil(evt.duration / SLOT_MINUTES);
527 | if (startSlotIdx + slotLength > state.slots) {
528 | alert('Passt nicht in die verbleibende Zeit.');
529 | return;
530 | }
531 | // Check if slots free in facility
532 | const overlap = state.events.some(other => other.scheduled && other.scheduled.facilityId === facilityId && other !== evt &&
533 | !( (other.scheduled.startSlot + other.scheduled.slotLength <= startSlotIdx) || (other.scheduled.startSlot >= startSlotIdx + slotLength) ) );
534 | if (overlap) {
535 | if (!confirm('Überlappung mit bestehender Belegung. Trotzdem platzieren?')) return;
536 | }
537 | evt.scheduled = { facilityId, startSlot: startSlotIdx, slotLength };
538 | buildUnassignedList();
539 | rebuildTimelines();
540 | setDirty();
541 | removeGhost();
542 | draggedScheduled = false;
543 | dragOffsetSlots = 0;
544 | }
545 |
546 | function showGhost(slotEl) {
547 | const eventId = draggedEventId;
548 | if (!eventId) return;
549 | const evt = state.events.find(ev => ev.id === eventId);
550 | if (!evt) return;
551 | const slotLength = Math.ceil(evt.duration / SLOT_MINUTES);
552 | const rawSlotIdx = parseInt(slotEl.dataset.slotIndex,10);
553 | // compute desired start slot relative to where the user grabbed the block
554 | let desiredStart = rawSlotIdx - dragOffsetSlots;
555 | if (desiredStart + slotLength > state.slots) desiredStart = state.slots - slotLength;
556 | if (desiredStart < 0) desiredStart = 0;
557 | const startSlotIdx = desiredStart;
558 | if (startSlotIdx + slotLength > state.slots) { removeGhost(); return; }
559 | const facilityId = slotEl.dataset.facilityId;
560 | const parent = slotEl.parentElement; // facility-slots grid
561 | if (!parent) return;
562 | removeGhost();
563 | slotEl = slotEl.parentElement.querySelector(`.slot[data-slot-index="${startSlotIdx}"]`);
564 | const ghost = document.createElement('div');
565 | ghost.className = 'event-block ghost';
566 | ghost.style.width = (slotLength * 36 - 6) + 'px';
567 | const displayGroup = evt.displayGroup || evt.age_group;
568 | ghost.innerHTML = `${displayGroup}
${evt.discipline}
`;
569 | ghost.style.pointerEvents = 'none';
570 | // color ghost text for visibility
571 | const ghostBase = state.ageGroupColors[evt.base_age_group || evt.age_group];
572 | const ghostShade = shadeForSquad(ghostBase, evt.squadIndex || 0) || '#2196f3';
573 | ghost.style.background = ghostShade;
574 | ghost.style.color = getContrast(ghost.style.background);
575 | // Position inside target slot
576 | slotEl.appendChild(ghost);
577 | ghostEl = ghost;
578 | }
579 |
580 | function removeGhost() {
581 | if (ghostEl && ghostEl.parentElement) ghostEl.parentElement.removeChild(ghostEl);
582 | ghostEl = null;
583 | }
584 |
585 | // Event listeners for controls
586 | function initControls() {
587 | document.getElementById('rebuildBtn').addEventListener('click', () => {
588 | const startTime = document.getElementById('startTime').value;
589 | const total = parseInt(document.getElementById('totalDuration').value,10);
590 | if (isNaN(total) || total <= 0) return alert('Ungültige Gesamtdauer');
591 | state.startTime = startTime;
592 | state.totalMinutes = total;
593 | rebuildTimelines();
594 | setDirty();
595 | });
596 | document.getElementById('addFacilityBtn').addEventListener('click', () => {
597 | const nameInput = document.getElementById('newFacilityName');
598 | const name = nameInput.value.trim();
599 | if (!name) return;
600 | state.facilities.push({ id: generateId(), name });
601 | nameInput.value = '';
602 | rebuildTimelines();
603 | setDirty();
604 | });
605 | // squad configuration dynamic inputs
606 | setupSquadConfigUI();
607 | // plan persistence controls
608 | setupPlanPersistenceUI();
609 | }
610 |
611 | function bootstrap() {
612 | initControls();
613 | // // Provide a couple default facilities for convenience
614 | // state.facilities = [
615 | // { id: generateId(), name: 'Weitsprung 1' },
616 | // { id: generateId(), name: 'Wurf 1' }
617 | // ];
618 | loadData();
619 | }
620 |
621 | document.addEventListener('DOMContentLoaded', bootstrap);
622 |
623 | // ---------- Plan Persistence (localStorage) ----------
624 | const LS_KEY_INDEX = 'zeitplaner.plan.index'; // stores array of plan names
625 | function loadPlanIndex() {
626 | try { return JSON.parse(localStorage.getItem(LS_KEY_INDEX)) || []; } catch { return []; }
627 | }
628 | function savePlanIndex(idx) { localStorage.setItem(LS_KEY_INDEX, JSON.stringify(idx)); }
629 | function planStorageKey(name) { return 'zeitplaner.plan.' + encodeURIComponent(name); }
630 |
631 | function serializeCurrentPlan() {
632 | return {
633 | version: 1,
634 | name: state.currentPlanName,
635 | startTime: state.startTime,
636 | totalMinutes: state.totalMinutes,
637 | facilities: state.facilities,
638 | ageGroupSquads: state.ageGroupSquads,
639 | baseEntries: state.baseEntries,
640 | events: state.events.map(e => ({
641 | id: e.id,
642 | base_age_group: e.base_age_group,
643 | age_group: e.age_group,
644 | displayGroup: e.displayGroup,
645 | squadIndex: e.squadIndex,
646 | discipline: e.discipline,
647 | duration: e.duration,
648 | scheduled: e.scheduled
649 | })),
650 | ageGroupColors: state.ageGroupColors,
651 | facilityColors: state.facilityColors
652 | };
653 | }
654 |
655 | function applyPlan(plan) {
656 | state.startTime = plan.startTime;
657 | state.totalMinutes = plan.totalMinutes;
658 | state.facilities = plan.facilities || [];
659 | state.ageGroupSquads = plan.ageGroupSquads || {};
660 | state.baseEntries = plan.baseEntries || [];
661 | state.events = (plan.events || []).map(e => ({ ...e }));
662 | state.ageGroups = new Set(state.baseEntries.map(e => e.age_group));
663 | state.ageGroupColors = plan.ageGroupColors || {};
664 | state.facilityColors = plan.facilityColors || {};
665 | state.currentPlanName = plan.name || null;
666 | // rebuild UI
667 | document.getElementById('startTime').value = state.startTime;
668 | document.getElementById('totalDuration').value = state.totalMinutes;
669 | buildUnassignedList();
670 | rebuildTimelines();
671 | setupSquadConfigUI();
672 | refreshPlanControls();
673 | setDirty(false);
674 | }
675 |
676 | function savePlan(existing=false) {
677 | if (!existing || !state.currentPlanName) {
678 | const name = prompt('Planname eingeben:', state.currentPlanName || 'Mein Plan');
679 | if (!name) return;
680 | state.currentPlanName = name.trim();
681 | }
682 | const planObj = serializeCurrentPlan();
683 | const key = planStorageKey(state.currentPlanName);
684 | localStorage.setItem(key, JSON.stringify(planObj));
685 | // update index
686 | let idx = loadPlanIndex();
687 | if (!idx.includes(state.currentPlanName)) { idx.push(state.currentPlanName); idx.sort(); savePlanIndex(idx); }
688 | refreshPlanControls();
689 | alert('Plan gespeichert.');
690 | setDirty(false);
691 | }
692 |
693 | function loadSelectedPlan() {
694 | const sel = document.getElementById('planLoadSelect');
695 | const name = sel.value;
696 | if (!name) return;
697 | if (dirty && !confirm('Ungespeicherte Änderungen verwerfen und Plan laden?')) return;
698 | const raw = localStorage.getItem(planStorageKey(name));
699 | if (!raw) return alert('Plan nicht gefunden.');
700 | try {
701 | const data = JSON.parse(raw);
702 | applyPlan(data);
703 | } catch (e) {
704 | alert('Fehler beim Laden des Plans: ' + e.message);
705 | }
706 | }
707 |
708 | function refreshPlanControls() {
709 | const nameSpan = document.getElementById('currentPlanName');
710 | if (nameSpan) nameSpan.textContent = state.currentPlanName ? state.currentPlanName : '(unbenannt)';
711 | const sel = document.getElementById('planLoadSelect');
712 | if (sel) {
713 | const current = sel.value;
714 | sel.innerHTML = '';
715 | loadPlanIndex().forEach(n => {
716 | const opt = document.createElement('option');
717 | opt.value = n; opt.textContent = n;
718 | if (n === current || n === state.currentPlanName) opt.selected = true;
719 | sel.appendChild(opt);
720 | });
721 | }
722 | }
723 |
724 | function setupPlanPersistenceUI() {
725 | refreshPlanControls();
726 | const saveBtn = document.getElementById('savePlanBtn');
727 | const saveAsBtn = document.getElementById('saveAsPlanBtn');
728 | const loadBtn = document.getElementById('loadPlanBtn');
729 | const deleteBtn = document.getElementById('deletePlanBtn');
730 | const exportBtn = document.getElementById('exportPlanBtn');
731 | if (saveBtn) saveBtn.addEventListener('click', () => savePlan(true));
732 | if (saveAsBtn) saveAsBtn.addEventListener('click', () => savePlan(false));
733 | if (loadBtn) loadBtn.addEventListener('click', loadSelectedPlan);
734 | if (deleteBtn) deleteBtn.addEventListener('click', () => {
735 | if (!state.currentPlanName) return alert('Kein Plan ausgewählt.');
736 | if (dirty && !confirm('Es gibt ungespeicherte Änderungen. Trotzdem löschen?')) return;
737 | if (!confirm(`Plan "${state.currentPlanName}" wirklich löschen?`)) return;
738 | const idx = loadPlanIndex().filter(n => n !== state.currentPlanName);
739 | savePlanIndex(idx);
740 | localStorage.removeItem(planStorageKey(state.currentPlanName));
741 | state.currentPlanName = null;
742 | refreshPlanControls();
743 | setDirty(false);
744 | });
745 | if (exportBtn) exportBtn.addEventListener('click', () => exportPlanTxt());
746 | const exportByFacBtn = document.getElementById('exportByFacilityBtn');
747 | const exportByAgeBtn = document.getElementById('exportByAgeGroupBtn');
748 | const exportJsonBtn = document.getElementById('exportJsonBtn');
749 | const importJsonFile = document.getElementById('importJsonFile');
750 | if (exportByFacBtn) exportByFacBtn.addEventListener('click', () => exportByFacilityCsv());
751 | if (exportByAgeBtn) exportByAgeBtn.addEventListener('click', () => exportByAgeGroupCsv());
752 | if (exportJsonBtn) exportJsonBtn.addEventListener('click', () => exportPlanJson());
753 | if (importJsonFile) importJsonFile.addEventListener('change', (ev) => {
754 | const f = ev.target.files && ev.target.files[0];
755 | if (!f) return;
756 | importPlanJson(f);
757 | // clear input so same file can be reselected later
758 | ev.target.value = null;
759 | });
760 | }
761 |
762 | function exportPlanJson() {
763 | const planObj = serializeCurrentPlan();
764 | const json = JSON.stringify(planObj, null, 2);
765 | const blob = new Blob([json], { type: 'application/json;charset=utf-8' });
766 | const url = URL.createObjectURL(blob);
767 | const a = document.createElement('a');
768 | a.href = url;
769 | a.download = (state.currentPlanName || 'plan') + '.json';
770 | document.body.appendChild(a);
771 | a.click();
772 | document.body.removeChild(a);
773 | URL.revokeObjectURL(url);
774 | }
775 |
776 | function importPlanJson(file) {
777 | const reader = new FileReader();
778 | reader.onload = (e) => {
779 | try {
780 | const text = e.target.result;
781 | const parsed = JSON.parse(text);
782 | // basic validation
783 | if (!parsed || typeof parsed !== 'object' || !parsed.events) return alert('Ungültiges Plan-JSON.');
784 | if (dirty && !confirm('Ungespeicherte Änderungen verwerfen und Plan importieren?')) return;
785 | applyPlan(parsed);
786 | alert('Plan importiert.');
787 | } catch (err) {
788 | alert('Fehler beim Einlesen der Datei: ' + err.message);
789 | }
790 | };
791 | reader.onerror = () => alert('Fehler beim Lesen der Datei.');
792 | reader.readAsText(file, 'utf-8');
793 | }
794 |
795 | function exportPlanTxt() {
796 | // Collect scheduled events and format: "HH:MM\tDiscipline\tAgeGroup [R#]"
797 | const scheduled = state.events.filter(e => e.scheduled).slice();
798 | if (!scheduled.length) return alert('Keine geplanten Einträge zum Exportieren.');
799 | // Sort by start time
800 | scheduled.sort((a,b) => a.scheduled.startSlot - b.scheduled.startSlot);
801 | const lines = scheduled.map(e => {
802 | const startMinutes = parseTimeToMinutes(state.startTime) + e.scheduled.startSlot * SLOT_MINUTES;
803 | const time = minutesToTime(startMinutes);
804 | const displayGroup = e.displayGroup || e.age_group;
805 | return `${time}\t${e.discipline}\t${displayGroup}`;
806 | });
807 | const blob = new Blob(['\uFEFF' + lines.join('\n')], { type: 'text/plain;charset=utf-8' });
808 | const url = URL.createObjectURL(blob);
809 | const a = document.createElement('a');
810 | a.href = url;
811 | const name = (state.currentPlanName || 'plan') + '.txt';
812 | a.download = name;
813 | document.body.appendChild(a);
814 | a.click();
815 | document.body.removeChild(a);
816 | URL.revokeObjectURL(url);
817 | }
818 |
819 | function exportByFacilityCsv() {
820 | // Columns: each facility name. For each facility, list visiting teams in time order (ignore empty slots)
821 | const facilities = state.facilities.slice();
822 | if (!facilities.length) return alert('Keine Anlagen vorhanden.');
823 | // map facilityId -> ordered list of displayGroup by startSlot
824 | const byFac = {};
825 | facilities.forEach(f => byFac[f.id] = []);
826 | state.events.filter(e => e.scheduled).sort((a,b) => a.scheduled.startSlot - b.scheduled.startSlot).forEach(e => {
827 | const fid = e.scheduled.facilityId;
828 | if (!byFac[fid]) byFac[fid] = [];
829 | const label = (e.displayGroup || e.age_group) + ' - ' + e.discipline;
830 | byFac[fid].push(label);
831 | });
832 | // compute max column height
833 | const maxRows = Math.max(...facilities.map(f => byFac[f.id].length));
834 | const sep = ';';
835 | const header = facilities.map(f => `"${f.name.replace(/"/g,'""')}"`).join(sep);
836 | const lines = [header];
837 | for (let r=0; r byFac[f.id][r] ? `"${String(byFac[f.id][r]).replace(/"/g,'""')}"` : '');
839 | lines.push(row.join(sep));
840 | }
841 | const blob = new Blob(['\uFEFF' + lines.join('\n')], { type: 'text/csv;charset=utf-8' });
842 | const url = URL.createObjectURL(blob);
843 | const a = document.createElement('a'); a.href = url; a.download = (state.currentPlanName||'plan') + '.anlagen.csv'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);
844 | }
845 |
846 | function exportByAgeGroupCsv() {
847 | // Columns: each displayGroup (age group + squad if present). For each displayGroup list the facilities they visit in chronological order
848 | const displayGroups = [...new Set(state.events.map(e => e.displayGroup || e.age_group))];
849 | if (!displayGroups.length) return alert('Keine Einträge zum Exportieren.');
850 | // map displayGroup -> ordered list of facilities
851 | const byGroup = {};
852 | displayGroups.forEach(g => byGroup[g] = []);
853 | state.events.filter(e => e.scheduled).sort((a,b) => a.scheduled.startSlot - b.scheduled.startSlot).forEach(e => {
854 | const key = e.displayGroup || e.age_group;
855 | const fname = state.facilities.find(f => f.id === e.scheduled.facilityId)?.name || '';
856 | const label = e.discipline + ' - ' + fname;
857 | byGroup[key].push(label);
858 | });
859 | const maxRows = Math.max(...displayGroups.map(g => byGroup[g].length));
860 | const sep = ';';
861 | const header = displayGroups.map(g => `"${String(g).replace(/"/g,'""')}"`).join(sep);
862 | const lines = [header];
863 | for (let r=0; r byGroup[g][r] ? `"${String(byGroup[g][r]).replace(/"/g,'""')}"` : '');
865 | lines.push(row.join(sep));
866 | }
867 | const blob = new Blob(['\uFEFF' + lines.join('\n')], { type: 'text/csv;charset=utf-8' });
868 | const url = URL.createObjectURL(blob);
869 | const a = document.createElement('a'); a.href = url; a.download = (state.currentPlanName||'plan') + '.ablauf.csv'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);
870 | }
871 |
872 | // --- Squads Logic ---
873 | function setupSquadConfigUI() {
874 | const container = document.getElementById('squadConfigList');
875 | if (!container) return;
876 | container.innerHTML = '';
877 | [...state.ageGroups].sort(compareAgeGroupLabels).forEach(group => {
878 | if (!(group in state.ageGroupSquads)) state.ageGroupSquads[group] = 1;
879 | const row = document.createElement('div');
880 | row.className = 'squad-config-row';
881 | const label = document.createElement('label');
882 | label.textContent = group;
883 | label.setAttribute('for', `squads_${group}`);
884 | const input = document.createElement('input');
885 | input.type = 'number';
886 | input.min = '1';
887 | input.max = '8';
888 | input.value = state.ageGroupSquads[group];
889 | input.id = `squads_${group}`;
890 | input.addEventListener('change', () => {
891 | let v = parseInt(input.value,10);
892 | if (isNaN(v) || v < 1) v = 1; if (v > 8) v = 8;
893 | state.ageGroupSquads[group] = v;
894 | regenerateEventsFromSquads();
895 | buildUnassignedList();
896 | rebuildTimelines();
897 | setupSquadConfigUI(); // refresh to reflect normalized value
898 | setDirty();
899 | });
900 | row.appendChild(label);
901 | row.appendChild(input);
902 | container.appendChild(row);
903 | });
904 | }
905 |
906 | function regenerateEventsFromSquads() {
907 | // Preserve already scheduled events where possible by matching (base_age_group, discipline, squadIndex)
908 | const oldEvents = state.events;
909 | const newEvents = [];
910 | state.baseEntries.forEach(entry => {
911 | const squadCount = state.ageGroupSquads[entry.age_group] || 1;
912 | for (let s = 0; s < squadCount; s++) {
913 | const existing = oldEvents.find(ev => ev.base_age_group === entry.age_group && ev.discipline === entry.discipline && ev.squadIndex === s);
914 | newEvents.push({
915 | id: existing ? existing.id : generateId(),
916 | base_age_group: entry.age_group,
917 | age_group: entry.age_group, // keep original for conflict logic
918 | displayGroup: squadCount > 1 ? `${entry.age_group} R${s+1}` : entry.age_group,
919 | squadIndex: s,
920 | discipline: entry.discipline,
921 | duration: entry.duration,
922 | scheduled: existing ? existing.scheduled : null
923 | });
924 | }
925 | });
926 | state.events = newEvents;
927 | }
928 |
929 | function shadeForSquad(hex, squadIndex) {
930 | if (!hex) return '#888';
931 | if (!/^#([0-9a-f]{6})$/i.test(hex)) return hex;
932 | if (squadIndex === 0) return hex;
933 | // Convert to HSL, adjust lightness & saturation steps for clearer differentiation
934 | const r = parseInt(hex.slice(1,3),16)/255;
935 | const g = parseInt(hex.slice(3,5),16)/255;
936 | const b = parseInt(hex.slice(5,7),16)/255;
937 | const max = Math.max(r,g,b), min = Math.min(r,g,b);
938 | let h, s, l = (max + min)/2;
939 | if (max === min) { h = s = 0; }
940 | else {
941 | const d = max - min;
942 | s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
943 | switch(max){
944 | case r: h = (g - b) / d + (g < b ? 6 : 0); break;
945 | case g: h = (b - r) / d + 2; break;
946 | case b: h = (r - g) / d + 4; break;
947 | }
948 | h /= 6;
949 | }
950 | // For each additional squad increase lightness and slightly reduce saturation
951 | const lightnessStep = 0.12; // bigger step for clearer difference
952 | const saturationStep = 0.08;
953 | const newL = Math.min(0.95, l + lightnessStep * squadIndex);
954 | const newS = Math.max(0.25, s - saturationStep * squadIndex);
955 | function hue2rgb(p, q, t){
956 | if(t < 0) t += 1;
957 | if(t > 1) t -= 1;
958 | if(t < 1/6) return p + (q - p) * 6 * t;
959 | if(t < 1/2) return q;
960 | if(t < 2/3) return p + (q - p) * (2/3 - t) * 6;
961 | return p;
962 | }
963 | let nr, ng, nb;
964 | if (newS === 0) {
965 | nr = ng = nb = newL; // achromatic
966 | } else {
967 | const q = newL < 0.5 ? newL * (1 + newS) : newL + newS - newL * newS;
968 | const p = 2 * newL - q;
969 | nr = hue2rgb(p, q, h + 1/3);
970 | ng = hue2rgb(p, q, h);
971 | nb = hue2rgb(p, q, h - 1/3);
972 | }
973 | const toHex = x => x.toString(16).padStart(2,'0');
974 | return `#${toHex(Math.round(nr*255))}${toHex(Math.round(ng*255))}${toHex(Math.round(nb*255))}`;
975 | }
976 |
--------------------------------------------------------------------------------