├── 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 |
11 |

Wettbewerb Zeitplaner

12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 25 | 26 |
27 |
28 | 31 | 34 | 35 |
36 |
37 | 38 | 39 |
40 |
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 | 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 | --------------------------------------------------------------------------------