PageSourceSearch

https://www.abensberg-fussball.de/js/turnier-core.js

js abensberg-fussball.de collected 2026-10-02 17:47:01 UTC 31,617 bytes, 774 lines download raw bytes

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    ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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.