1/* ========================================================================= 2 TSV ABENSBERG 1862 · ABTEILUNG FUSSBALL 3 turnier-core.js â Turnier-Engine (Modell, Spielplan, Tabellen, Speicher) 4 5 Wird von turniere.html (öffentlich) und admin/turniere.html (Verwaltung) 6 gemeinsam genutzt. Kein Build-Schritt, keine Abhängigkeiten. 7 8 Speicher-Reihenfolge (TP.store): 9 1. /api/turniere â echtes Backend (Cloudflare Function + D1) 10 2. data/turniere.json â im Repo gepflegt (Read-only für die Ãffentlichkeit) 11 3. Browser-Speicher â Arbeitsstand in der Verwaltung 12 ========================================================================= */ 13(function (global) { 14 'use strict'; 15 16 const TP = {}; 17 18 /* Fassungsnummer. Die Verwaltung prueft sie beim Start. Passen Engine und 19 Oberflaeche nicht zusammen, gibt es eine klare Meldung statt einer leeren 20 Seite â das hat schon einmal wie Datenverlust ausgesehen. */ 21 TP.VERSION = 4; 22 23 /* ---------------------------------------------------------------- Helfer */ 24 TP.uid = () => Math.random().toString(36).slice(2, 8) + Date.now().toString(36).slice(-3); 25 26 TP.pad = n => String(n).padStart(2, '0'); 27 28 TP.toMin = hhmm => { 29 const m = /^(\d{1,2}):(\d{2})$/.exec(String(hhmm || '').trim()); 30 return m ? (+m[1]) * 60 + (+m[2]) : 0; 31 }; 32 TP.toTime = min => TP.pad(Math.floor((min % 1440) / 60)) + ':' + TP.pad(min % 60); 33 TP.addMin = (hhmm, min) => TP.toTime(TP.toMin(hhmm) + min); 34 35 TP.fmtDate = iso => { 36 if (!iso) return ''; 37 const [y, m, d] = iso.split('-'); 38 return `${d}.${m}.${y}`; 39 }; 40 TP.fmtDateLong = iso => { 41 if (!iso) return ''; 42 const dt = new Date(iso + 'T12:00:00'); 43 return dt.toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' }); 44 }; 45 TP.esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => 46 ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); 47 48 TP.slug = s => String(s || '').toLowerCase() 49 .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/Ã/g, 'ss') 50 .replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60); 51 52 /* ------------------------------------------------------------ Vorlagen */ 53 TP.GRUPPEN = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']; 54 55 /* Die Turnierserien des Vereins. Die Liste wird nirgends gepflegt, sondern 56 ergaenzt sich selbst: was einmal vergeben wurde, steht beim naechsten Mal 57 zur Auswahl. Diese drei sind fest gesetzt, damit sie auch dann da sind, 58 wenn noch kein Turnier der Serie angelegt wurde. */ 59 TP.SERIEN_VORGABE = ['Hallenturnier dahoam', 'GVK Cup', 'Pepperl Paintl Cup']; 60 61 TP.serien = (turniere = []) => { 62 const alle = TP.SERIEN_VORGABE.concat(turniere.map(t => (t.serie || '').trim())); 63 return [...new Set(alle.filter(Boolean))].sort((a, b) => a.localeCompare(b, 'de')); 64 }; 65 66 /** Kennzeichnungen der Altersklassen, ebenfalls selbstlernend. */ 67 TP.KLASSEN_VORGABE = ['Bambini', 'G-Junioren', 'F-Junioren', 'E-Junioren', 'D-Junioren', 68 'C-Junioren', 'B-Junioren', 'A-Junioren', 'Herren', 'Damen', 'Alte Herren']; 69 70 TP.klassen = (turniere = []) => { 71 const alle = TP.KLASSEN_VORGABE.concat(turniere.map(t => (t.altersklasse || '').trim())); 72 return [...new Set(alle.filter(Boolean))]; 73 }; 74 75 TP.ENDRUNDEN = { 76 keine: 'Keine Endrunde (nur Tabelle)', 77 finale: 'Finale', 78 p3finale: 'Spiel um Platz 3 + Finale', 79 platzierung: 'Alle Plätze überkreuz (â¦, P5, P3, Finale)', 80 halbfinale: 'Halbfinals + Spiel um Platz 3 + Finale' 81 }; 82 83 TP.SORT_LABEL = { 84 punkte: 'Punkte', 85 direkt: 'Direkter Vergleich', 86 tordifferenz: 'Tordifferenz', 87 tore: 'Erzielte Tore', 88 gegentore: 'Wenigste Gegentore' 89 }; 90 91 TP.neu = (over = {}) => Object.assign({ 92 id: TP.uid(), 93 name: 'Neues Turnier', 94 serie: 'Hallenturnier dahoam', 95 altersklasse: '', 96 status: 'geplant', // geplant | live | beendet 97 veroeffentlicht: false, 98 datum: '', 99 ort: 'Aventinus-Halle Abensberg', 100 veranstalter: 'TSV Abensberg 1862 e.V. · Abteilung FuÃball', 101 info: '', 102 meldeschluss: '', 103 startgeld: 40, 104 namensrecht: { sponsor: '', logo: '', url: '' }, 105 sponsoren: [], 106 modus: { typ: 'gruppen', gruppen: 2, hinRueck: false, endrunde: 'p3finale' }, 107 spielzeit: { start: '09:00', dauer: 10, pause: 2, felder: 1, endrundePause: 10 },
108 wertung: { sieg: 3, remis: 1, rangliste: true, sortierung: ['punkte', 'direkt', 'tordifferenz', 'tore'] }, 109 teams: [], 110 spiele: [], 111 meldungen: [] 112 }, over); 113 114 TP.neuesTeam = (name, gruppe) => ({ id: TP.uid(), name: name || '', gruppe: gruppe || 'A', verein: '', kontakt: '', telefon: '' }); 115 116 /* ------------------------------------------------------ Gruppen / Teams */ 117 TP.gruppenNamen = t => { 118 const n = t.modus.typ === 'liga' ? 1 : Math.max(1, Math.min(8, +t.modus.gruppen || 1)); 119 return TP.GRUPPEN.slice(0, n); 120 }; 121 TP.teamsDerGruppe = (t, g) => t.teams.filter(x => x.gruppe === g); 122 TP.team = (t, id) => t.teams.find(x => x.id === id) || null; 123 124 /** Verteilt alle Mannschaften gleichmäÃig (Schlangensystem) auf die Gruppen. */ 125 TP.auslosen = (t, mischen = true) => { 126 const gs = TP.gruppenNamen(t); 127 const list = t.teams.slice(); 128 if (mischen) for (let i = list.length - 1; i > 0; i--) { 129 const j = Math.floor(Math.random() * (i + 1)); 130 [list[i], list[j]] = [list[j], list[i]]; 131 } 132 list.forEach((tm, i) => { 133 const runde = Math.floor(i / gs.length); 134 const pos = runde % 2 ? gs.length - 1 - (i % gs.length) : i % gs.length; 135 tm.gruppe = gs[pos]; 136 }); 137 return t; 138 }; 139 140 /* ------------------------------------------------------- Spielplan-Bau */ 141 /** Berger-Tabelle: jeder gegen jeden, rundenweise. */ 142 function berger(ids) { 143 const list = ids.slice(); 144 const BYE = '__frei__'; 145 if (list.length % 2) list.push(BYE); 146 const m = list.length, rounds = []; 147 if (m < 2) return rounds; 148 for (let r = 0; r < m - 1; r++) { 149 const pairs = []; 150 for (let i = 0; i < m / 2; i++) { 151 const a = list[i], b = list[m - 1 - i]; 152 if (a !== BYE && b !== BYE) pairs.push(r % 2 ? [b, a] : [a, b]); 153 } 154 rounds.push(pairs); 155 list.splice(1, 0, list.pop()); 156 } 157 return rounds; 158 } 159 TP.berger = berger; 160 161 /** Verschiebt Spiele so, dass eine Mannschaft möglichst nicht direkt zweimal ran muss. */ 162 function pausenVerteilen(list, felder) { 163 const belegt = (idx, sp) => { 164 const von = Math.max(0, idx - Math.max(1, felder)); 165 for (let k = von; k < idx; k++) { 166 const p = list[k]; 167 if (!p) continue; 168 if (p.heim === sp.heim || p.gast === sp.heim || p.heim === sp.gast || p.gast === sp.gast) return true; 169 } 170 return false; 171 }; 172 for (let i = 0; i < list.length; i++) { 173 if (!belegt(i, list[i])) continue; 174 for (let j = i + 1; j < list.length; j++) { 175 if (!belegt(i, list[j])) { const tmp = list[i]; list[i] = list[j]; list[j] = tmp; break; } 176 } 177 } 178 return list; 179 } 180 181 /** Baut Endrunden-Paarungen als Platzhalter-Referenzen. 182 Referenzformat: G:A:1 = Erster der Gruppe A · W:12 = Sieger Spiel 12 · L:12 = Verlierer Spiel 12 */ 183 function endrunde(t, nrAb) { 184 const gs = TP.gruppenNamen(t), mode = t.modus.endrunde, out = []; 185 const groesse = Math.min(...gs.map(g => TP.teamsDerGruppe(t, g).length || 99)); 186 const add = (titel, heim, gast) => { out.push({ phase: 'endrunde', titel, heim, gast }); return nrAb + out.length - 1; }; 187 188 if (mode === 'keine' || gs.length === 0) return out; 189 190 if (gs.length === 1) { 191 if (mode !== 'keine') add('Finale', 'G:A:1', 'G:A:2'); 192 return out; 193 } 194 if (mode === 'finale') { add('Finale', `G:${gs[0]}:1`, `G:${gs[1]}:1`); return out; } 195 196 if (mode === 'p3finale') { 197 add('Spiel um Platz 3', `G:${gs[0]}:2`, `G:${gs[1]}:2`); 198 add('Finale', `G:${gs[0]}:1`, `G:${gs[1]}:1`); 199 return out; 200 } 201 if (mode === 'platzierung') { 202 for (let rang = groesse === 99 ? 4 : groesse; rang >= 1; rang--) { 203 const platz = rang * 2 - 1; 204 add(platz === 1 ? 'Finale' : `Spiel um Platz ${platz}`, `G:${gs[0]}:${rang}`, `G:${gs[1]}:${rang}`); 205 } 206 return out; 207 } 208 if (mode === 'halbfinale') { 209 if (gs.length === 2) { 210 const hf1 = add('Halbfinale 1', `G:${gs[0]}:1`, `G:${gs[1]}:2`); 211 const hf2 = add('Halbfinale 2', `G:${gs[1]}:1`, `G:${gs[0]}:2`); 212 add('Spiel um Platz 3', `L:${hf1}`, `L:${hf2}`); 213 add('Finale', `W:${hf1}`, `W:${hf2}`); 214 } else { 215 const paare = []; 216 for (let i = 0; i < gs.length;
216 i += 2) paare.push([gs[i], gs[i + 1]].filter(Boolean)); 217 const nrs = paare.map((p, i) => 218 add(`Halbfinale ${i + 1}`, `G:${p[0]}:1`, `G:${p[1] || p[0]}:2`)); 219 if (nrs.length >= 2) { 220 add('Spiel um Platz 3', `L:${nrs[0]}`, `L:${nrs[1]}`); 221 add('Finale', `W:${nrs[0]}`, `W:${nrs[1]}`); 222 } 223 } 224 return out; 225 } 226 return out; 227 } 228 229 /** Erzeugt den kompletten Spielplan. Vorhandene Ergebnisse werden verworfen. */ 230 TP.spielplanErzeugen = t => { 231 const gs = TP.gruppenNamen(t); 232 const proGruppe = gs.map(g => { 233 const ids = TP.teamsDerGruppe(t, g).map(x => x.id); 234 let rd = berger(ids); 235 if (t.modus.hinRueck) rd = rd.concat(berger(ids).map(r => r.map(([a, b]) => [b, a]))); 236 return { g, rd }; 237 }); 238 const flach = []; 239 const maxR = Math.max(0, ...proGruppe.map(x => x.rd.length)); 240 for (let r = 0; r < maxR; r++) { 241 proGruppe.forEach(pg => (pg.rd[r] || []).forEach(([a, b]) => 242 flach.push({ phase: 'gruppe', gruppe: pg.g, runde: r + 1, heim: a, gast: b }))); 243 } 244 pausenVerteilen(flach, +t.spielzeit.felder || 1); 245 246 const felder = Math.max(1, +t.spielzeit.felder || 1); 247 const takt = (+t.spielzeit.dauer || 10) + (+t.spielzeit.pause || 0); 248 let letzteZeit = TP.toMin(t.spielzeit.start); 249 250 flach.forEach((sp, i) => { 251 const slot = Math.floor(i / felder); 252 sp.feld = (i % felder) + 1; 253 sp.zeit = TP.toTime(TP.toMin(t.spielzeit.start) + slot * takt); 254 letzteZeit = TP.toMin(t.spielzeit.start) + slot * takt; 255 }); 256 257 const er = endrunde(t, flach.length + 1); 258 let z = letzteZeit + takt + (+t.spielzeit.endrundePause || 0); 259 er.forEach((sp, i) => { 260 const slot = Math.floor(i / felder); 261 sp.feld = (i % felder) + 1; 262 sp.zeit = TP.toTime(z + slot * takt); 263 }); 264 265 t.spiele = flach.concat(er).map((sp, i) => Object.assign({ 266 id: TP.uid(), nr: i + 1, toreHeim: null, toreGast: null, 267 status: 'offen', ausserKonkurrenz: false, notiz: '', neunmeter: '' 268 }, sp)); 269 return t; 270 }; 271 272 /** Zeiten neu rechnen, Ergebnisse behalten. */ 273 TP.zeitenNeu = t => { 274 const felder = Math.max(1, +t.spielzeit.felder || 1); 275 const takt = (+t.spielzeit.dauer || 10) + (+t.spielzeit.pause || 0); 276 const grp = t.spiele.filter(s => s.phase === 'gruppe'); 277 const end = t.spiele.filter(s => s.phase === 'endrunde'); 278 let letzte = TP.toMin(t.spielzeit.start); 279 grp.forEach((sp, i) => { 280 const slot = Math.floor(i / felder); 281 sp.feld = (i % felder) + 1; 282 sp.zeit = TP.toTime(TP.toMin(t.spielzeit.start) + slot * takt); 283 letzte = TP.toMin(t.spielzeit.start) + slot * takt; 284 }); 285 const z = letzte + takt + (+t.spielzeit.endrundePause || 0); 286 end.forEach((sp, i) => { 287 const slot = Math.floor(i / felder); 288 sp.feld = (i % felder) + 1; 289 sp.zeit = TP.toTime(z + slot * takt); 290 }); 291 return t; 292 }; 293 294 /* --------------------------------------------------------- Referenzen */ 295 TP.refLabel = (t, ref) => { 296 if (!ref) return 'â'; 297 const tm = TP.team(t, ref); 298 if (tm) return tm.name || 'â'; 299 let m = /^G:([A-H]):(\d+)$/.exec(ref); 300 if (m) return `${m[2]}. Gruppe ${m[1]}`; 301 m = /^W:(\d+)$/.exec(ref); 302 if (m) return `Sieger Spiel ${m[1]}`; 303 m = /^L:(\d+)$/.exec(ref); 304 if (m) return `Verlierer Spiel ${m[1]}`; 305 return 'â'; 306 }; 307 308 /** Löst eine Referenz auf, sobald das Ergebnis feststeht. Gibt teamId oder null. */ 309 TP.refTeamId = (t, ref) => { 310 if (!ref) return null; 311 if (TP.team(t, ref)) return ref; 312 let m = /^G:([A-H]):(\d+)$/.exec(ref); 313 if (m) { 314 const g = m[1], rang = +m[2]; 315 const offen = t.spiele.some(s => s.phase === 'gruppe' && s.gruppe === g && s.status !== 'beendet'); 316 if (offen) return null; 317 const row = TP.tabelle(t, g)[rang - 1]; 318 return row ? row.team.id : null; 319 } 320 m = /^([WL]):(\d+)$/.exec(ref); 321 if (m) { 322 const sp = t.spiele.find(s => s.nr === +m[2]); 323 if (!sp || sp.status !== 'beendet') return null; 324 const sieger = TP.sieger(t, sp); 325 if (!sieger) return null; 326 const h = TP.refTeamId(t, sp.heim), g = TP.refTeamId(t, sp.gast); 327 return m[1] === 'W' ? sieger : (sieger === h ? g : h); 328 } 329 return null; 330 }; 331 332 TP.spielName = (t, sp, seite) => { 333 const ref = sp[seite]; 334 const id = TP.refTeamId(t, ref); 335 return id ? (TP.team(t, id).name || 'â') : TP.refLabel(t, ref); 336 }; 337 338 TP.sieger = (t, sp) => { 339 if (sp.status !== 'beendet' || sp.toreHeim == null || sp.toreGast == null) return null; 340 const h = TP.refTeamId(t, sp.heim), g = TP.refTeamId(t, sp.gast); 341 if (sp.toreHeim > sp.toreGast) return h; 342 if (sp.toreGast > sp.toreHeim) return g; 343 const nm = /^(\d+)\s*[:\-]\s*(\d+)$/.exec(String(sp.neunmeter || '')); 344 if (nm) return (+nm[1] > +nm[2]) ? h : g; 345 return null; 346 }; 347 348 /* ------------------------------------------------------------ Tabellen */ 349 TP.tabelle = (t, g) => { 350 const rows = TP.teamsDerGruppe(t, g).map(tm => ({ 351 team: tm, sp: 0, s: 0, u: 0, n: 0, tp: 0, tm: 0, pkt: 0, diff: 0 352 })); 353 const idx = {}; rows.forEach(r => idx[r.team.id] = r); 354 const sieg = +t.wertung.sieg || 3, rem = +t.wertung.remis || 1; 355 const spiele = t.spiele.filter(s => s.phase === 'gruppe' && s.gruppe === g 356 && s.status === 'beendet' && !s.ausserKonkurrenz && s.toreHeim != null && s.toreGast != null); 357 358 spiele.forEach(s => { 359 const h = idx[s.heim], a = idx[s.gast]; 360 if (!h || !a) return; 361 h.sp++; a.sp++; 362 h.tp += +s.toreHeim; h.tm += +s.toreGast; 363 a.tp += +s.toreGast; a.tm += +s.toreHeim; 364 if (+s.toreHeim > +s.toreGast) { h.s++; a.n++; h.pkt += sieg; a.pkt += 0; } 365 else if (+s.toreHeim < +s.toreGast) { a.s++; h.n++; a.pkt += sieg; } 366 else { h.u++; a.u++; h.pkt += rem; a.pkt += rem; } 367 }); 368 rows.forEach(r => r.diff = r.tp - r.tm); 369 370 /* Direkter Vergleich der beiden verglichenen Mannschaften: 371 zuerst Punkte aus den Spielen gegeneinander, dann Tordifferenz, dann Tore. */ 372 const direkt = (a, b) => { 373 let pa = 0, pb = 0, ta = 0, tb = 0, gefunden = false;
374 spiele.forEach(s => { 375 const aHeim = s.heim === a.team.id && s.gast === b.team.id; 376 const bHeim = s.heim === b.team.id && s.gast === a.team.id; 377 if (!aHeim && !bHeim) return; 378 gefunden = true; 379 const aTore = aHeim ? +s.toreHeim : +s.toreGast; 380 const bTore = aHeim ? +s.toreGast : +s.toreHeim; 381 ta += aTore; tb += bTore; 382 if (aTore > bTore) pa += sieg; 383 else if (bTore > aTore) pb += sieg; 384 else { pa += rem; pb += rem; } 385 }); 386 if (!gefunden) return 0; 387 if (pa !== pb) return pb - pa; 388 return tb - ta; // bei zwei Mannschaften sind Tordifferenz und Tore identisch geordnet 389 }; 390 391 const krit = { 392 punkte: (a, b) => b.pkt - a.pkt, 393 direkt: direkt, 394 tordifferenz: (a, b) => b.diff - a.diff, 395 tore: (a, b) => b.tp - a.tp, 396 gegentore: (a, b) => a.tm - b.tm 397 }; 398 const reihe = (t.wertung.sortierung || ['punkte', 'direkt', 'tordifferenz', 'tore']); 399 rows.sort((a, b) => { 400 for (const k of reihe) { const r = (krit[k] || (() => 0))(a, b); if (r) return r; } 401 return String(a.team.name).localeCompare(String(b.team.name), 'de'); 402 }); 403 rows.forEach((r, i) => r.platz = i + 1); 404 return rows; 405 }; 406 407 /* ------------------------------------------------------------- Auskunft */ 408 TP.fortschritt = t => { 409 const ges = t.spiele.length; 410 const fertig = t.spiele.filter(s => s.status === 'beendet').length; 411 return { ges, fertig, prozent: ges ? Math.round(fertig / ges * 100) : 0 }; 412 }; 413 TP.naechsteSpiele = (t, n = 5) => t.spiele.filter(s => s.status !== 'beendet').slice(0, n); 414 TP.letzteErgebnisse = (t, n = 5) => t.spiele.filter(s => s.status === 'beendet').slice(-n).reverse(); 415 416 TP.statusLabel = t => ({ geplant: 'Geplant', live: 'Läuft', beendet: 'Beendet' }[t.status] || t.status); 417 418 TP.pruefen = t => { 419 const w = []; 420 if (!t.name.trim()) w.push('Turniername fehlt.'); 421 if (!t.datum) w.push('Datum fehlt.'); 422 if (t.teams.length < 2) w.push('Mindestens 2 Mannschaften nötig.'); 423 TP.gruppenNamen(t).forEach(g => { 424 const n = TP.teamsDerGruppe(t, g).length; 425 if (n === 0) w.push(`Gruppe ${g} ist leer.`); 426 else if (n === 1) w.push(`Gruppe ${g} hat nur eine Mannschaft.`); 427 }); 428 if (!t.spiele.length) w.push('Spielplan noch nicht erzeugt.'); 429 if (t.modus.endrunde === 'platzierung' && TP.gruppenNamen(t).length !== 2) 430 w.push('Ãberkreuz-Platzierungsspiele brauchen genau 2 Gruppen.'); 431 const doppelt = {}; 432 t.teams.forEach(x => { const k = x.name.trim().toLowerCase(); if (k) { doppelt[k] = (doppelt[k] || 0) + 1; } }); 433 Object.keys(doppelt).filter(k => doppelt[k] > 1).forEach(k => w.push(`Mannschaft â${k}" doppelt erfasst.`)); 434 return w; 435 }; 436 437 /* ------------------------------------------------------------- Speicher */ 438 const KEY = 'tsv.turniere.v1'; 439 440 const memStore = { data: null }; 441 442 function lsGet() { 443 try { const r = localStorage.getItem(KEY); return r ? JSON.parse(r) : null; } catch (e) { return null; } 444 } 445 function lsSet(v) { 446 try { localStorage.setItem(KEY, JSON.stringify(v)); return true; } catch (e) { return false; } 447 } 448 449 TP.store = { 450 quelle: 'â', 451 nurLokal: [], // Turnier-Kennungen, die noch nicht in der Datenbank stehen 452 anderswoGeloescht: [], // auf einem anderen Geraet geloescht, hier entfernt 453 454 455 /** Lädt Turniere: API â Repo-JSON â Browser-Speicher â Demodaten. */ 456 async laden(opts = {}) { 457 const { apiZuerst = true, jsonPfad = 'data/turniere.json', demo = null } = opts; 458 459 const lokal = lsGet(); 460 461 if (apiZuerst) { 462 try { 463 const r = await fetch('/api/turniere?v=' + Date.now(), { headers: { accept: 'application/json' } }); 464 if (r.ok) { 465 const j = await r.json(); 466 if (Array.isArray(j.turniere)) { 467 TP.store.quelle = 'D1 über /api/turniere'; 468 let liste = j.turniere; 469 TP.store.nurLokal = []; 470 471 /* In der Verwaltung darf ein Turnier nicht verschwinden, nur weil es 472 noch nicht in der Datenbank steht â etwa weil beim ersten Versuch 473 der Schreibschluessel fehlte. Solche Turniere kommen aus dem 474 Browserspeicher dazu und werden anschliessend hochgeschoben. 475 Fuer alles, was die Datenbank kennt, gilt die Datenbank. */ 476 liste.forEach(t => t._sync = Date.now()); // kommt aus der Datenbank 477 478 if (opts.arbeitsstand && lokal && Array.isArray(lokal.turniere)) { 479 const inDb = new Set(liste.map(t => t.id)); 480 const fehlend = lokal.turniere.filter(t => t && t.id && !inDb.has(t.id)); 481 482 /* Unterscheidung, die zaehlt: 483 ohne _sync = wurde nie hochgeladen -> mitnehmen und hochschieben 484 mit _sync = war schon mal in der Datenbank und ist dort jetzt weg 485 -> jemand anderes hat es geloescht. Nicht wieder 486 auferstehen lassen, sonst kaempfen zwei Rechner 487 gegeneinander. */ 488 const neuLokal = fehlend.filter(t => !t._sync); 489 const geloescht = fehlend.filter(t => t._sync); 490 491 if (neuLokal.length) { 492 liste = liste.concat(neuLokal); 493 TP.store.nurLokal = neuLokal.map(t => t.id); 494 TP.store.quelle = 'D1 + ' + neuLokal.length + ' noch nicht übertragen'; 495 } 496 if (geloescht.length) { 497 TP.store.anderswoGeloescht = geloescht.map(t => t.name || t.id); 498 lsSet({ stand: new Date().toISOString(), 499 turniere: lokal.turniere.filter(t => !geloescht.includes(t)) }); 500 } 501 } 502 return liste; 503 } 504 } 505 } catch (e) { /* kein Backend â weiter */ } 506 } 507
508 try { 509 const r = await fetch(jsonPfad + '?v=' + Date.now(), { headers: { accept: 'application/json' } }); 510 if (r.ok) { 511 const j = await r.json(); 512 const list = Array.isArray(j) ? j : (j.turniere || []); 513 if (list.length) { 514 if (lokal && lokal.stand && lokal.turniere) { 515 TP.store.quelle = 'data/turniere.json + Arbeitsstand'; 516 return TP.mischen(list, lokal.turniere); 517 } 518 TP.store.quelle = 'data/turniere.json'; 519 return list; 520 } 521 } 522 } catch (e) { /* keine Datei â weiter */ } 523 524 if (lokal && lokal.turniere) { TP.store.quelle = 'Browser-Speicher'; return lokal.turniere; } 525 if (memStore.data) { TP.store.quelle = 'Sitzung'; return memStore.data; } 526 if (demo) { TP.store.quelle = 'Demodaten'; return demo; } 527 return []; 528 }, 529 530 /** Arbeitsstand sichern (Verwaltung). Fällt auf Sitzungsspeicher zurück. 531 Die Herkunftsangabe wird dabei nicht überschrieben: kam die Liste aus der 532 Datenbank, bleibt die Datenbank die Wahrheit â der Browserspeicher ist 533 dann nur die Rückfallebene und keine neue Quelle. */ 534 sichern(turniere) { 535 memStore.data = turniere; 536 const ok = lsSet({ stand: new Date().toISOString(), turniere }); 537 if (!/^D1|^API/.test(TP.store.quelle)) { 538 TP.store.quelle = ok ? 'Browser-Speicher' : 'Sitzung (nicht dauerhaft)'; 539 } else if (!ok) { 540 TP.store.quelle += ' (lokale Sicherung fehlgeschlagen)'; 541 } 542 return ok; 543 }, 544 545 leeren() { 546 memStore.data = null; 547 try { localStorage.removeItem(KEY); } catch (e) { } 548 } 549 }; 550 551 /** Repo-Stand + lokaler Stand zusammenführen (lokal gewinnt je Turnier-ID). */ 552 TP.mischen = (basis, lokal) => { 553 const out = basis.slice(); 554 lokal.forEach(l => { 555 const i = out.findIndex(x => x.id === l.id); 556 if (i >= 0) out[i] = l; else out.push(l); 557 }); 558 return out; 559 }; 560 561 /* ------------------------------------------------------------------ Live */ 562 /* Das Geruest eines Turniers wird einmal geladen. Danach fragt die Seite 563 nur noch /api/turniere/<id>/stand ab â ein paar hundert Byte. Kommt 564 âgleich: true" zurueck, hat sich nichts geaendert und es wird nicht neu 565 gezeichnet. */ 566 567 const ST_ZURUECK = ['offen', 'live', 'beendet']; 568 569 TP.live = { 570 verfuegbar: false, 571 /** Prueft einmalig, ob ein Backend antwortet. */ 572 async pruefen() { 573 try { 574 const r = await fetch('/api/turniere?v=' + Date.now(), { headers: { accept: 'application/json' } }); 575 TP.live.verfuegbar = r.ok; 576 return r.ok; 577 } catch (e) { TP.live.verfuegbar = false; return false; } 578 }, 579 580 /** 581 * Holt den Stand und mischt ihn in das Turnierobjekt. 582 * Gibt { geaendert:boolean, v:number } zurueck. 583 */ 584 async stand(t) { 585 if (!t || !t.id) return { geaendert: false, v: 0 }; 586 const seit = t._v || 0; 587 let d;
588 try { 589 const key = TP.puffer && TP.puffer.schluessel 590 ? '&key=' + encodeURIComponent(TP.puffer.schluessel) : ''; 591 const r = await fetch(`/api/turniere/${encodeURIComponent(t.id)}/stand?v=${seit}${key}`, 592 { headers: { accept: 'application/json' } }); 593 if (!r.ok) return { geaendert: false, v: seit, fehler: r.status }; 594 d = await r.json(); 595 } catch (e) { return { geaendert: false, v: seit, fehler: 'offline' }; } 596 597 if (d.gleich) { t._v = d.v; return { geaendert: false, v: d.v }; } 598 599 let geaendert = false; 600 if (d.s && d.s !== t.status) { t.status = d.s; geaendert = true; } 601 602 const nachNr = {}; 603 t.spiele.forEach(sp => nachNr[sp.nr] = sp); 604 (d.e || []).forEach(([nr, h, g, st, nm]) => { 605 const sp = nachNr[nr]; 606 if (!sp) return; 607 const neu = { toreHeim: h, toreGast: g, status: ST_ZURUECK[st] || 'offen', neunmeter: nm || '' }; 608 if (sp.toreHeim !== neu.toreHeim || sp.toreGast !== neu.toreGast || 609 sp.status !== neu.status || (sp.neunmeter || '') !== neu.neunmeter) { 610 Object.assign(sp, neu); geaendert = true; 611 } 612 }); 613 t._v = d.v; 614 return { geaendert, v: d.v }; 615 } 616 }; 617 618 /** Ergebnisse fuer mehrere Turniere nachladen und einmischen. 619 Noetig, weil /api/turniere nur das Geruest liefert â die Ergebnisse 620 liegen in einer eigenen Tabelle und kommen ueber /stand. */ 621 TP.live.staendeLaden = async (turniere = []) => { 622 let geaendert = 0; 623 await Promise.all(turniere.map(async t => { 624 t._v = 0; // vollstaendig holen, nicht nur die Aenderung 625 const r = await TP.live.stand(t); 626 if (r.geaendert) geaendert++; 627 })); 628 return geaendert; 629 }; 630 631 /* --------------------------------------------------------- Schreibpuffer */ 632 /* Das Hallen-WLAN faellt aus. Genau dafuer: jede Eingabe geht in eine 633 Warteschlange, wird sofort versucht und bei Misserfolg wiederholt. 634 Die Warteschlange uebersteht auch ein Neuladen der Seite. */ 635 636 const PKEY = 'tsv.turniere.puffer.v1'; 637 638 TP.puffer = { 639 liste: (() => { try { return JSON.parse(localStorage.getItem(PKEY)) || []; } catch (e) { return []; } })(), 640 laeuft: false, 641 schluessel: null, // optionaler Schreibschluessel fuer das Helfer-Tablet 642 beiAenderung: null, // Rueckmeldung an die Oberflaeche 643 644 _sichern() { 645 try { localStorage.setItem(PKEY, JSON.stringify(TP.puffer.liste)); } catch (e) { } 646 if (TP.puffer.beiAenderung) TP.puffer.beiAenderung(TP.puffer.stand()); 647 }, 648 649 letzterFehler: null, // z. B. { status:403, text:'Nicht berechtigt' } 650 651 stand() { 652 return { offen: TP.puffer.liste.length, laeuft: TP.puffer.laeuft, 653 fehler: TP.puffer.letzterFehler }; 654 }, 655 656 /** Ein Ergebnis vormerken und sofort senden versuchen. */ 657 ergebnis(turnierId, spiel) { 658 const i = TP.puffer.liste.findIndex(x => 659 x.turnier === turnierId && x.spiel && x.spiel.nr === spiel.nr); 660 const eintrag = { turnier: turnierId, spiel, t: Date.now() }; 661 if (i >= 0) TP.puffer.liste[i] = eintrag; else TP.puffer.liste.push(eintrag); 662 TP.puffer._sichern(); 663 TP.puffer.senden(); 664 }, 665 666 /** Turnierstatus vormerken. */ 667 turnierStatus(turnierId, status) { 668 TP.puffer.liste.push({ turnier: turnierId, status, t: Date.now() }); 669 TP.puffer._sichern(); 670 TP.puffer.senden(); 671 }, 672 673 async senden() { 674 if (TP.puffer.laeuft || !TP.puffer.liste.length) return; 675 TP.puffer.laeuft = true; 676 if (TP.puffer.beiAenderung) TP.puffer.beiAenderung(TP.puffer.stand()); 677 678 /* Alles zu einem Turnier in einem Aufruf zusammenfassen. */ 679 const gruppen = {}; 680 TP.puffer.liste.forEach(e => { 681 const g = gruppen[e.turnier] || (gruppen[e.turnier] = { turnier: e.turnier, spiele: [] }); 682 if (e.spiel) g.spiele.push(e.spiel); 683 if (e.status) g.status = e.status; 684 }); 685 686 let alleOk = true; 687 for (const g of Object.values(gruppen)) { 688 const url = '/api/admin/ergebnis' + 689 (TP.puffer.schluessel ? '?key=' + encodeURIComponent(TP.puffer.schluessel) : ''); 690 try { 691 const r = await fetch(url, { 692 method: 'POST', 693 headers: { 'content-type': 'application/json' }, 694 body: JSON.stringify(g) 695 }); 696 if (!r.ok) { 697 /* Nicht stumm im Kreis laufen â der Grund gehoert auf den Schirm. */ 698 let text = ''; 699 try { text = (await r.json()).fehler || ''; } catch (e) { } 700 TP.puffer.letzterFehler = { status: r.status, text: text || r.statusText, 701 hinweis: TP.puffer.hinweisZu(r.status, text) }; 702 alleOk = false; break; 703 } 704 TP.puffer.letzterFehler = null; 705 TP.puffer.liste = TP.puffer.liste.filter(e => e.turnier !== g.turnier); 706 } catch (e) { 707 TP.puffer.letzterFehler = { status: 0, text: 'offline', 708 hinweis: 'Keine Verbindung â wird automatisch nachgeholt.' }; 709 alleOk = false; break; 710 } 711 } 712 713 TP.puffer.laeuft = false;
714 TP.puffer._sichern(); 715 716 /* Fehlgeschlagenes erneut versuchen. Bei einem Berechtigungsfehler 717 seltener, weil das ohne Zutun sowieso nicht besser wird. */ 718 if (!alleOk && TP.puffer.liste.length) { 719 const st = TP.puffer.letzterFehler && TP.puffer.letzterFehler.status; 720 setTimeout(TP.puffer.senden, (st === 403 || st === 404) ? 30000 : 5000); 721 } 722 }, 723 724 hinweisZu(status, text) { 725 if (status === 403) return 'Schreiben abgelehnt. Es fehlt der Schreibschluessel ' 726 + '(Adresse einmal mit ?key=⦠oeffnen) oder Cloudflare Access vor /api/admin.'; 727 if (status === 404) return 'Das Turnier steht noch nicht in der Datenbank. ' 728 + 'Erst unter âVeroeffentlichen" einmal Entwurf und wieder Veroeffentlicht klicken.'; 729 if (status === 501) return 'Keine Datenbank gebunden. Binding DB im Pages-Projekt pruefen.'; 730 return text || ('Fehler ' + status); 731 }, 732 733 /** Ein ganzes Turniergeruest speichern (Anlegen, Spielplan, Veroeffentlichen). */ 734 async geruest(turnier, spielplanNeu) { 735 const url = '/api/admin/turnier' + 736 (TP.puffer.schluessel ? '?key=' + encodeURIComponent(TP.puffer.schluessel) : ''); 737 try { 738 const r = await fetch(url, { 739 method: 'PUT', headers: { 'content-type': 'application/json' }, 740 body: JSON.stringify(Object.assign({}, turnier, { spielplanNeu: !!spielplanNeu })) 741 }); 742 if (r.ok) turnier._sync = Date.now(); 743 return r.ok; 744 } catch (e) { return false; } 745 }, 746 747 async loeschen(id) { 748 const url = '/api/admin/turnier?id=' + encodeURIComponent(id) + 749 (TP.puffer.schluessel ? '&key=' + encodeURIComponent(TP.puffer.schluessel) : ''); 750 try { const r = await fetch(url, { method: 'DELETE' }); return r.ok; } 751 catch (e) { return false; } 752 } 753 }; 754 755 /* ------------------------------------------------------------- Migration */ 756 /** Ergänzt fehlende Felder aus älteren Ständen. */ 757 TP.normalisieren = t => { 758 const n = TP.neu(); 759 const out = Object.assign({}, n, t); 760 out.namensrecht = Object.assign({}, n.namensrecht, t.namensrecht || {}); 761 out.modus = Object.assign({}, n.modus, t.modus || {}); 762 out.spielzeit = Object.assign({}, n.spielzeit, t.spielzeit || {}); 763 out.wertung = Object.assign({}, n.wertung, t.wertung || {}); 764 out.teams = (t.teams || []).map(x => Object.assign(TP.neuesTeam(), x)); 765 out.spiele = (t.spiele || []).slice(); 766 out.sponsoren = (t.sponsoren || []).slice(); 767 out.meldungen = (t.meldungen || []).slice(); 768 if (t._sync) out._sync = t._sync; 769 if (t._v) out._v = t._v; 770 return out; 771 }; 772 773 global.TP = TP; 774})(window);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.