Erste Version: HCRB Spieltag-Kalender und Fahrtplaner

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-10 13:54:33 +02:00
commit 10400eee59
30 files changed
+3771

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fahrtplaner – HCRB</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<main class="narrow">
<div id="from-event" class="notice no-print" hidden></div>
<div class="card no-print">
<div class="card-head">
<h2>Fahrt</h2>
<button type="button" id="reset-btn" class="small">Eingabe zurücksetzen</button>
</div>
<div class="grid2">
<label>Ziel / Event
<input type="text" id="trip-title" placeholder="z. B. Spieltag Waldfeld">
</label>
<label>Datum
<input type="date" id="trip-date">
</label>
</div>
<label>Treffpunkt (Standard)
<select id="trip-point"></select>
</label>
<p id="trip-points-empty" class="muted" hidden>Treffpunkte legst du unter <a href="/gruppe">Gruppe</a> an.</p>
<div class="grid2">
<label>Abfahrt
<input type="time" id="trip-departure" value="07:00" required>
</label>
<label>Treffzeit
<input type="time" id="trip-meet" value="06:45" required>
</label>
</div>
</div>
<div class="card no-print">
<div class="card-head">
<h2>Wer kommt mit?</h2>
<div class="row">
<button type="button" id="select-all" class="small">Alle</button>
<button type="button" id="select-none" class="small">Keine</button>
</div>
</div>
<p id="trip-members-empty" class="muted" hidden>Lege zuerst Mitglieder unter <a href="/gruppe">Gruppe</a> an.</p>
<ul id="trip-members" class="list"></ul>
</div>
<div class="card no-print">
<h2>Weitere Personen (nur diese Fahrt)</h2>
<form id="guest-form" class="row wrap">
<input type="text" id="guest-name" placeholder="Vorname" required autocomplete="off">
<label class="inline"><input type="checkbox" id="guest-hascar"> Hat Auto</label>
<input type="number" id="guest-seats" min="1" max="8" value="4" title="Freie Plätze (ohne Fahrer)" hidden>
<select id="guest-rideswith" title="Fährt am liebsten mit bei"></select>
<button type="submit">Hinzufügen</button>
</form>
<ul id="guests-list" class="list"></ul>
</div>
<div class="card no-print">
<h2>Feste Zuteilung (nur diese Fahrt)</h2>
<p class="muted">Wer hier eingetragen ist, fährt sicher bei diesem Fahrer mit – der Fahrer fährt dann auf jeden Fall.
Alle anderen werden bei der Berechnung automatisch verteilt.</p>
<form id="fixed-form" class="row wrap">
<select id="fixed-passenger" title="Wer"></select>
<span class="muted">fährt bei</span>
<select id="fixed-driver" title="Fahrer"></select>
<button type="submit">Festlegen</button>
</form>
<ul id="fixed-list" class="list"></ul>
</div>
<div class="card">
<h2 class="no-print">Einteilung</h2>
<div class="no-print">
<label class="inline">
<input type="checkbox" id="opt-min-cars" checked> Möglichst wenige Autos verwenden
</label>
<p class="muted">Wer „fährt immer“ hat und sein Auto dabei hat, fährt trotzdem selbst und bekommt zuerst Mitfahrer.</p>
<p class="muted">Treffpunkt und Zeiten kannst du danach bei jedem Auto einzeln ändern.
Fahrer mit eigenem Standard-Treffpunkt (unter Gruppe) starten automatisch dort.</p>
<div class="row">
<button id="plan-btn" class="primary">Fahrgemeinschaften berechnen</button>
</div>
</div>
<div id="result"></div>
</div>
</main>
<script type="module" src="/js/fahrt.js"></script>
</body>
</html>
+85
View File
@@ -0,0 +1,85 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Gruppe – HCRB</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<main class="narrow">
<div class="card">
<h2 id="member-form-title">Mitglied hinzufügen</h2>
<form id="member-form">
<input type="hidden" id="member-id">
<label>Vorname
<input type="text" id="member-name" required autocomplete="off" maxlength="50">
</label>
<label class="inline">
<input type="checkbox" id="member-external"> Extern (gehört nicht zur Gruppe, fährt aber manchmal mit)
</label>
<label class="inline">
<input type="checkbox" id="member-hascar"> Hat Auto
</label>
<label>Fährt am liebsten mit bei
<select id="member-rideswith"></select>
</label>
<div id="member-seats-wrap" hidden>
<label>Freie Plätze (ohne Fahrer)
<input type="number" id="member-seats" min="1" max="8" value="4">
</label>
<label class="inline">
<input type="checkbox" id="member-always"> Kommt immer mit eigenem Auto
</label>
<p class="muted">Fährt dann bei jeder Fahrt selbst, auch wenn weniger Autos reichen würden.</p>
<label>Standard-Treffpunkt, wenn er/sie fährt
<select id="member-point"></select>
</label>
</div>
<div class="row">
<button type="submit" class="primary">Speichern</button>
<button type="button" id="member-cancel" hidden>Abbrechen</button>
</div>
</form>
</div>
<div class="card">
<h2>Gruppe</h2>
<p id="members-empty" class="muted" hidden>Noch keine Mitglieder gespeichert.</p>
<ul id="members-list" class="list"></ul>
</div>
<div class="card">
<h2 id="point-form-title">Treffpunkt hinzufügen</h2>
<form id="point-form">
<input type="hidden" id="point-id">
<label>Name
<input type="text" id="point-name" required autocomplete="off" maxlength="60" placeholder="z. B. Parkplatz Bahnhof">
</label>
<label>Adresse
<input type="text" id="point-address" required autocomplete="off" maxlength="200" placeholder="Straße Nr., PLZ Ort">
</label>
<div class="row">
<button type="submit" class="primary">Speichern</button>
<button type="button" id="point-cancel" hidden>Abbrechen</button>
</div>
</form>
<p id="points-empty" class="muted" hidden>Noch keine Treffpunkte gespeichert.</p>
<ul id="points-list" class="list"></ul>
</div>
<div class="card">
<h2>Daten sichern</h2>
<p class="muted">Gruppe als JSON-Datei sichern oder eine gesicherte Datei wieder einspielen (ersetzt die aktuelle Gruppe).</p>
<div class="row">
<button id="export-btn">Exportieren (JSON)</button>
<label class="button">Importieren
<input type="file" id="import-file" accept="application/json" hidden>
</label>
</div>
</div>
</main>
<script type="module" src="/js/gruppe.js"></script>
</body>
</html>
+519
View File
@@ -0,0 +1,519 @@
import {
$, api, esc, sortByName, seatsLabel, showError, renderHeader, formatDate,
} from './common.js';
renderHeader('fahrt');
let members = [];
let points = [];
const DEFAULT_DEPARTURE = '07:00';
const MEET_BEFORE_MIN = 15;
// Zustand der aktuellen Fahrt (nur im Browser, wird nicht gespeichert)
const trip = {
selected: new Set(), // IDs der mitfahrenden Mitglieder
bringsCar: new Map(), // ID -> bool, ob das Auto diesmal dabei ist
guests: [], // { id, name, hasCar, seats, guest: true }
fixed: new Map(), // Mitfahrer-ID -> Fahrer-ID, feste Zuteilung für diese Fahrt
};
let guestCounter = 0;
// ---------- Treffpunkt und Uhrzeiten ----------
function renderPoints() {
$('#trip-points-empty').hidden = points.length > 0;
$('#trip-point').innerHTML = points.length
? points.map((p) => `<option value="${p.id}">${esc(p.name)} – ${esc(p.address)}</option>`).join('')
+ '<option value="">(kein Treffpunkt)</option>'
: '<option value="">(noch keine Treffpunkte angelegt)</option>';
}
// "07:00" minus 15 Minuten → "06:45" (auch über Mitternacht)
function minusMinutes(time, min) {
const [h, m] = time.split(':').map(Number);
const total = (((h * 60 + m - min) % 1440) + 1440) % 1440;
return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
}
function setDeparture(time) {
$('#trip-departure').value = time;
$('#trip-meet').value = minusMinutes(time, MEET_BEFORE_MIN);
}
// Treffzeit folgt der Abfahrt, kann danach aber noch von Hand angepasst werden
$('#trip-departure').addEventListener('change', (e) => {
if (e.target.value) setDeparture(e.target.value);
});
// ---------- Mitglieder auswählen ----------
function renderTripMembers() {
$('#trip-members-empty').hidden = members.length > 0;
const nameOf = (id) => members.find((m) => m.id === id)?.name;
$('#trip-members').innerHTML = sortByName(members).map((m) => {
if (!trip.bringsCar.has(m.id)) trip.bringsCar.set(m.id, m.hasCar);
const checked = trip.selected.has(m.id);
return `
<li>
<label class="inline name">
<input type="checkbox" data-select="${m.id}" ${checked ? 'checked' : ''}> ${esc(m.name)}${m.external ? ' <span class="badge">Extern</span>' : ''}
${nameOf(m.ridesWith) ? `<span class="meta">(gern bei ${esc(nameOf(m.ridesWith))})</span>` : ''}
</label>
<span class="actions">
${m.hasCar ? `
<label class="inline meta">
<input type="checkbox" data-car="${m.id}" ${trip.bringsCar.get(m.id) ? 'checked' : ''} ${checked ? '' : 'disabled'}>
Auto dabei (${seatsLabel(m.seats)})
</label>
${m.alwaysDrives ? '<span class="badge" title="Kommt immer mit eigenem Auto">fährt immer</span>' : ''}` : '<span class="meta">kein Auto</span>'}
</span>
</li>`;
}).join('');
renderFixed();
}
$('#trip-members').addEventListener('change', (e) => {
const { select, car } = e.target.dataset;
if (select) {
e.target.checked ? trip.selected.add(select) : trip.selected.delete(select);
renderTripMembers();
}
if (car) {
trip.bringsCar.set(car, e.target.checked);
renderFixed();
}
});
$('#select-all').addEventListener('click', () => {
members.forEach((m) => trip.selected.add(m.id));
renderTripMembers();
});
$('#select-none').addEventListener('click', () => {
trip.selected.clear();
renderTripMembers();
});
// ---------- Gäste ----------
$('#guest-hascar').addEventListener('change', (e) => {
$('#guest-seats').hidden = !e.target.checked;
});
$('#guest-form').addEventListener('submit', (e) => {
e.preventDefault();
const name = $('#guest-name').value.trim();
if (!name) return;
const hasCar = $('#guest-hascar').checked;
trip.guests.push({
id: 'guest-' + ++guestCounter,
name,
hasCar,
seats: hasCar ? Math.max(1, parseInt($('#guest-seats').value, 10) || 1) : 0,
ridesWith: $('#guest-rideswith').value || null,
guest: true,
});
e.target.reset();
$('#guest-seats').hidden = true;
renderGuests();
$('#guest-name').focus();
});
function renderGuests() {
const people = [...sortByName(members), ...trip.guests];
const nameOf = (id) => people.find((p) => p.id === id)?.name;
$('#guest-rideswith').innerHTML = '<option value="">(keine Vorliebe)</option>'
+ people.map((p) => `<option value="${p.id}">gern bei ${esc(p.name)}</option>`).join('');
$('#guests-list').innerHTML = trip.guests.map((g) => `
<li>
<span class="name">${esc(g.name)} <span class="badge">Gast</span>
${nameOf(g.ridesWith) ? `<span class="meta">(gern bei ${esc(nameOf(g.ridesWith))})</span>` : ''}</span>
<span class="meta">${g.hasCar ? `Auto · ${seatsLabel(g.seats)}` : 'kein Auto'}</span>
<button class="link danger" data-remove="${g.id}">Entfernen</button>
</li>`).join('');
renderFixed();
}
$('#guests-list').addEventListener('click', (e) => {
const id = e.target.dataset.remove;
if (!id) return;
trip.guests = trip.guests.filter((g) => g.id !== id);
for (const g of trip.guests) if (g.ridesWith === id) g.ridesWith = null;
renderGuests();
});
// ---------- Feste Zuteilung ----------
// Alle, die diesmal mitfahren (Mitglieder mit Auto-Häkchen der Fahrt, plus Gäste)
function currentParticipants() {
return [
...sortByName(members.filter((m) => trip.selected.has(m.id))).map((m) => ({
...m,
hasCar: m.hasCar && trip.bringsCar.get(m.id) !== false,
})),
...trip.guests,
];
}
function renderFixed() {
const people = currentParticipants();
const byId = new Map(people.map((p) => [p.id, p]));
const canDrive = (p) => p?.hasCar && p.seats > 0;
// Einträge entfernen, die nicht mehr passen (abgewählt, Auto nicht dabei, Auto voll)
const used = new Map();
for (const [passengerId, driverId] of trip.fixed) {
const n = (used.get(driverId) || 0) + 1;
if (!byId.has(passengerId) || !canDrive(byId.get(driverId)) || trip.fixed.has(driverId)
|| n > byId.get(driverId).seats) {
trip.fixed.delete(passengerId);
} else {
used.set(driverId, n);
}
}
const fixedDrivers = new Set(trip.fixed.values());
const passengers = people.filter((p) => !trip.fixed.has(p.id) && !fixedDrivers.has(p.id));
const drivers = people.filter((p) => canDrive(p) && !trip.fixed.has(p.id) && (used.get(p.id) || 0) < p.seats);
$('#fixed-passenger').innerHTML = passengers.length
? passengers.map((p) => `<option value="${p.id}">${esc(p.name)}</option>`).join('')
: '<option value="">(niemand mehr frei)</option>';
$('#fixed-driver').innerHTML = drivers.length
? drivers.map((d) => `<option value="${d.id}">${esc(d.name)} (${d.seats - (used.get(d.id) || 0)} frei)</option>`).join('')
: '<option value="">(kein Auto dabei)</option>';
$('#fixed-form button').disabled = !passengers.length || !drivers.length;
$('#fixed-list').innerHTML = [...trip.fixed].map(([passengerId, driverId]) => `
<li>
<span class="name">${esc(byId.get(passengerId).name)} <span class="muted">fährt bei</span> ${esc(byId.get(driverId).name)}</span>
<button class="link danger" data-unfix="${passengerId}">Entfernen</button>
</li>`).join('');
}
$('#fixed-form').addEventListener('submit', (e) => {
e.preventDefault();
const passengerId = $('#fixed-passenger').value;
const driverId = $('#fixed-driver').value;
if (!passengerId || !driverId || passengerId === driverId) return;
trip.fixed.set(passengerId, driverId);
renderFixed();
});
$('#fixed-list').addEventListener('click', (e) => {
const id = e.target.dataset.unfix;
if (!id) return;
trip.fixed.delete(id);
renderFixed();
});
// ---------- Zurücksetzen ----------
function resetTrip() {
trip.selected.clear();
trip.bringsCar.clear();
trip.guests = [];
trip.fixed.clear();
$('#trip-title').value = '';
$('#trip-date').value = '';
$('#trip-point').selectedIndex = 0;
setDeparture(DEFAULT_DEPARTURE);
$('#opt-min-cars').checked = true;
$('#guest-form').reset();
$('#guest-seats').hidden = true;
$('#result').innerHTML = '';
$('#from-event').hidden = true;
history.replaceState(null, '', location.pathname);
renderTripMembers();
renderGuests();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
$('#reset-btn').addEventListener('click', resetTrip);
// ---------- Einteilung berechnen ----------
/**
* Verteilt die Teilnehmer auf Autos.
* - Feste Zuteilung (fixed: Mitfahrer-ID -> Fahrer-ID): der Fahrer fährt sicher, der Mitfahrer sitzt sicher bei ihm.
* - Wer immer mit eigenem Auto kommt (alwaysDrives) und es dabeihat, fährt auf jeden Fall.
* - minCars: weitere Autos nur so viele wie nötig (größte zuerst), sonst alle Autos.
* Wer lieber bei einem anderen Autobesitzer mitfährt (ridesWith), wird erst zuletzt Fahrer.
* - Wunsch-Mitfahrer: wer bei einem Fahrer mitfahren möchte, kommt zuerst in dessen Auto;
* Mitfahrer mit Vorliebe füreinander kommen nach Möglichkeit ins selbe Auto.
* - Übrige Mitfahrer: zuerst bekommt jedes Auto jemanden (Immer-Fahrer zuerst), danach gleichmäßig auffüllen.
*/
function planTrip(participants, minCars, fixed = new Map()) {
const byId = new Map(participants.map((p) => [p.id, p]));
const hasUsableCar = (p) => !!(p?.hasCar && p.seats > 0);
const prefersOtherCar = (p) => hasUsableCar(byId.get(p.ridesWith));
const fixedDrivers = new Set(fixed.values());
const mustDrive = (p) => p.alwaysDrives || fixedDrivers.has(p.id);
// Fest zugeteilte Mitfahrer fahren nie selbst
const carOwners = participants.filter((p) => hasUsableCar(p) && !fixed.has(p.id));
const forced = carOwners.filter(mustDrive).sort((a, b) => b.seats - a.seats);
const optional = carOwners.filter((p) => !mustDrive(p))
.sort((a, b) => prefersOtherCar(a) - prefersOtherCar(b) || b.seats - a.seats);
let drivers = [...forced, ...optional];
if (minCars) {
drivers = [...forced];
let capacity = forced.reduce((sum, d) => sum + 1 + d.seats, 0);
for (const owner of optional) {
if (capacity >= participants.length) break;
drivers.push(owner);
capacity += 1 + owner.seats;
}
}
const driverIds = new Set(drivers.map((d) => d.id));
const cars = drivers.map((d) => ({ driver: d, passengers: [] }));
const carOf = new Map(cars.map((c) => [c.driver.id, c]));
const unassigned = [];
const free = (car) => car.driver.seats - car.passengers.length;
const place = (p, car) => { car.passengers.push(p); carOf.set(p.id, car); };
// Auto mit den wenigsten Mitfahrern, bei Gleichstand Immer-Fahrer, dann die meisten freien Plätze
function pickCar(size) {
let best = null;
for (const car of cars) {
if (free(car) < size) continue;
if (!best
|| car.passengers.length < best.passengers.length
|| (car.passengers.length === best.passengers.length
&& (car.driver.alwaysDrives && !best.driver.alwaysDrives
|| (!!car.driver.alwaysDrives === !!best.driver.alwaysDrives && free(car) > free(best))))) {
best = car;
}
}
return best;
}
// 0. Feste Zuteilung für diese Fahrt
for (const p of participants) {
const car = carOf.get(fixed.get(p.id));
if (car && car.driver.id === fixed.get(p.id) && free(car) > 0) place(p, car);
}
// 1. Wer bei einem Fahrer mitfahren möchte, kommt in dessen Auto
let rest = [];
for (const p of participants) {
if (carOf.has(p.id)) continue;
const car = carOf.get(p.ridesWith);
if (car && car.driver.id === p.ridesWith && free(car) > 0) place(p, car);
else rest.push(p);
}
// 2. Übrige Mitfahrer in Gruppen zusammenfassen, die über Vorlieben verbunden sind
const groupOf = new Map(rest.map((p) => [p.id, [p]]));
for (const p of rest) {
const a = groupOf.get(p.id);
const b = groupOf.get(p.ridesWith);
if (!b || a === b) continue;
a.push(...b);
for (const q of b) groupOf.set(q.id, a);
}
const groups = [...new Set(groupOf.values())].sort((a, b) => b.length - a.length);
// 3. Gruppen verteilen – bevorzugt ins Auto, in dem die Wunschperson schon sitzt
for (const group of groups) {
const wished = group.map((p) => carOf.get(p.ridesWith)).find((c) => c && free(c) >= group.length);
const car = wished || pickCar(group.length);
if (car) {
group.forEach((p) => place(p, car));
continue;
}
for (const p of group) {
const own = carOf.get(p.ridesWith);
const c = own && free(own) > 0 ? own : pickCar(1);
c ? place(p, c) : unassigned.push(p);
}
}
return { cars, unassigned };
}
// ---------- Ergebnis ----------
const badges = (p) => (p.guest ? ' <span class="badge">Gast</span>' : '')
+ (p.external ? ' <span class="badge">Extern</span>' : '');
const sameMeeting = (a, b) => a.pointId === b.pointId && a.meet === b.meet && a.departure === b.departure;
function meetingLines({ pointId, meet, departure }) {
const point = points.find((p) => p.id === pointId);
return [
point && `Treffpunkt: ${point.name}, ${point.address}`,
meet && `Treffzeit: ${meet} Uhr`,
departure && `Abfahrt: ${departure} Uhr`,
].filter(Boolean);
}
function meetingHtml({ pointId, meet, departure }) {
const point = points.find((p) => p.id === pointId);
let html = '<dl class="meeting">';
if (point) {
const maps = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(point.address)}`;
html += `<dt>Treffpunkt</dt><dd>${esc(point.name)}
<small><a href="${maps}" target="_blank" rel="noopener">${esc(point.address)}</a></small></dd>`;
}
if (meet) html += `<dt>Treffzeit</dt><dd>${esc(meet)} Uhr</dd>`;
if (departure) html += `<dt>Abfahrt</dt><dd>${esc(departure)} Uhr</dd>`;
return html + '</dl>';
}
// Haben alle Autos denselben Treffpunkt und dieselben Zeiten, steht das einmal oben, sonst bei jedem Auto
function commonMeeting(carMeet, defaults) {
if (!carMeet.length) return defaults;
return carMeet.every((m) => sameMeeting(m, carMeet[0])) ? carMeet[0] : null;
}
function buildText(heading, { cars, unassigned }, carMeet, defaults) {
const lines = [`Fahrgemeinschaften${heading ? ': ' + heading : ''}`, ''];
const common = commonMeeting(carMeet, defaults);
if (common) {
const ml = meetingLines(common);
if (ml.length) lines.push(...ml, '');
}
cars.forEach((car, i) => {
if (i > 0 && !common) lines.push('');
lines.push(`Fahrzeug ${i + 1} – Fahrer: ${car.driver.name} (${car.passengers.length}/${car.driver.seats} belegt)`);
if (!common) meetingLines(carMeet[i]).forEach((l) => lines.push(` ${l}`));
car.passengers.forEach((p) => lines.push(` • ${p.name}`));
if (!car.passengers.length) lines.push(' • (keine Mitfahrer)');
});
if (unassigned.length) {
lines.push('', `OHNE PLATZ: ${unassigned.map((p) => p.name).join(', ')}`);
}
return lines.join('\n');
}
$('#plan-btn').addEventListener('click', () => {
const participants = currentParticipants();
const result = $('#result');
if (!participants.length) {
result.innerHTML = '<p class="warn">Bitte wähle mindestens eine Person aus.</p>';
return;
}
const plan = planTrip(participants, $('#opt-min-cars').checked, trip.fixed);
const heading = [$('#trip-title').value.trim(), formatDate($('#trip-date').value)].filter(Boolean).join(' – ');
const totalSeats = plan.cars.reduce((s, c) => s + 1 + c.driver.seats, 0);
// Treffpunkt und Zeiten pro Auto: Standard-Treffpunkt des Fahrers, sonst der Treffpunkt der Fahrt
const defaults = {
pointId: $('#trip-point').value,
meet: $('#trip-meet').value,
departure: $('#trip-departure').value,
};
const carMeet = plan.cars.map((car) => ({
...defaults,
pointId: points.some((p) => p.id === car.driver.meetingPoint) ? car.driver.meetingPoint : defaults.pointId,
}));
const pointOptions = (selected) => points
.map((p) => `<option value="${p.id}" ${p.id === selected ? 'selected' : ''}>${esc(p.name)}</option>`).join('')
+ `<option value="" ${selected ? '' : 'selected'}>(kein Treffpunkt)</option>`;
let html = heading ? `<h3>${esc(heading)}</h3>` : '';
html += '<div id="meeting-common"></div>';
html += `<p class="muted">${participants.length} Personen · ${plan.cars.length} ${plan.cars.length === 1 ? 'Auto' : 'Autos'} · ${totalSeats} Sitzplätze</p>`;
html += '<div class="cars">' + plan.cars.map((car, i) => `
<div class="car">
<h3>Auto ${i + 1}: ${esc(car.driver.name)}${badges(car.driver)}</h3>
<div class="muted">${car.passengers.length} / ${car.driver.seats} Plätze belegt</div>
<div class="car-meet no-print">
<label>Treffpunkt <select data-car-point="${i}">${pointOptions(carMeet[i].pointId)}</select></label>
<div class="grid2">
<label>Treffzeit <input type="time" data-car-meet="${i}" value="${esc(carMeet[i].meet)}"></label>
<label>Abfahrt <input type="time" data-car-dep="${i}" value="${esc(carMeet[i].departure)}"></label>
</div>
</div>
<div data-car-info="${i}"></div>
${car.passengers.length
? `<ol>${car.passengers.map((p) => `<li>${esc(p.name)}${badges(p)}${trip.fixed.get(p.id) === car.driver.id ? ' <span class="badge">fest</span>' : ''}</li>`).join('')}</ol>`
: '<p class="muted">keine Mitfahrer</p>'}
</div>`).join('') + '</div>';
if (plan.unassigned.length) {
const missing = plan.unassigned.length;
html += `<div class="warn"><strong>Nicht genug Plätze!</strong> Es fehlen ${seatsLabel(missing)}.<br>
Ohne Platz: ${plan.unassigned.map((p) => esc(p.name)).join(', ')}</div>`;
}
html += `
<div class="row wrap no-print">
<button id="copy-btn">Als Text kopieren</button>
<button id="print-btn">Drucken</button>
<button id="reset-after-btn">Neue Fahrt</button>
</div>`;
result.innerHTML = html;
function renderMeetings() {
const common = commonMeeting(carMeet, defaults);
$('#meeting-common').innerHTML = common ? meetingHtml(common) : '';
carMeet.forEach((m, i) => {
$(`[data-car-info="${i}"]`).innerHTML = common ? '' : meetingHtml(m);
});
}
renderMeetings();
result.querySelector('.cars').addEventListener('change', (e) => {
const { carPoint, carMeet: meetIdx, carDep } = e.target.dataset;
if (carPoint !== undefined) carMeet[carPoint].pointId = e.target.value;
if (meetIdx !== undefined) carMeet[meetIdx].meet = e.target.value;
if (carDep !== undefined) {
// Treffzeit folgt der Abfahrt wie oben bei der Fahrt
const m = carMeet[carDep];
m.departure = e.target.value;
if (m.departure) {
m.meet = minusMinutes(m.departure, MEET_BEFORE_MIN);
$(`[data-car-meet="${carDep}"]`).value = m.meet;
}
}
$('#copy-btn').textContent = 'Als Text kopieren';
renderMeetings();
});
$('#copy-btn').addEventListener('click', async (e) => {
const text = buildText(heading, plan, carMeet, defaults);
try {
await navigator.clipboard.writeText(text);
e.target.textContent = 'Kopiert';
} catch {
prompt('Text zum Kopieren:', text);
}
});
$('#print-btn').addEventListener('click', () => window.print());
$('#reset-after-btn').addEventListener('click', resetTrip);
});
// ---------- Übernahme aus Kalender-Termin ----------
async function applyEventFromUrl() {
const eventId = new URLSearchParams(location.search).get('termin');
if (!eventId) return;
let ev;
try {
ev = await api('GET', `/events/${encodeURIComponent(eventId)}`);
} catch {
showError(new Error('Der Termin wurde nicht gefunden.'));
return;
}
const known = ev.attendees.filter((id) => members.some((m) => m.id === id));
known.forEach((id) => trip.selected.add(id));
$('#trip-title').value = ev.title;
$('#trip-date').value = ev.date;
const notice = $('#from-event');
notice.innerHTML = `Übernommen aus dem Kalender: <strong>${esc(ev.title)}</strong> am ${formatDate(ev.date, true)} –
${known.length} ${known.length === 1 ? 'Zusage ist' : 'Zusagen sind'} schon als „kommt mit“ ausgewählt.`;
notice.hidden = false;
}
// ---------- Start ----------
async function init() {
[members, points] = await Promise.all([api('GET', '/members'), api('GET', '/meeting-points')]);
renderPoints();
await applyEventFromUrl();
renderTripMembers();
renderGuests();
}
init().catch(showError);
+216
View File
@@ -0,0 +1,216 @@
import { $, api, esc, sortByName, seatsLabel, showError, renderHeader } from './common.js';
renderHeader('gruppe');
let members = [];
let points = [];
const form = $('#member-form');
$('#member-hascar').addEventListener('change', (e) => {
$('#member-seats-wrap').hidden = !e.target.checked;
});
// Auswahl "fährt am liebsten mit bei" – alle anderen Mitglieder
function fillRidesWith(selfId, selected) {
$('#member-rideswith').innerHTML = '<option value="">(keine Vorliebe)</option>'
+ sortByName(members.filter((m) => m.id !== selfId))
.map((m) => `<option value="${m.id}" ${m.id === selected ? 'selected' : ''}>${esc(m.name)}</option>`).join('');
}
// Auswahl Standard-Treffpunkt
function fillMemberPoint(selected) {
$('#member-point').innerHTML = '<option value="">(Treffpunkt der Fahrt)</option>'
+ points.map((p) => `<option value="${p.id}" ${p.id === selected ? 'selected' : ''}>${esc(p.name)}</option>`).join('');
}
function resetForm() {
form.reset();
fillRidesWith();
fillMemberPoint();
$('#member-id').value = '';
$('#member-seats-wrap').hidden = true;
$('#member-cancel').hidden = true;
$('#member-form-title').textContent = 'Mitglied hinzufügen';
}
$('#member-cancel').addEventListener('click', resetForm);
form.addEventListener('submit', async (e) => {
e.preventDefault();
const body = {
name: $('#member-name').value.trim(),
external: $('#member-external').checked,
hasCar: $('#member-hascar').checked,
seats: $('#member-seats').value,
alwaysDrives: $('#member-always').checked,
ridesWith: $('#member-rideswith').value || null,
meetingPoint: $('#member-point').value || null,
};
const id = $('#member-id').value;
try {
if (id) await api('PUT', `/members/${id}`, body);
else await api('POST', '/members', body);
resetForm();
await load();
} catch (err) {
showError(err);
}
});
function edit(id) {
const m = members.find((x) => x.id === id);
$('#member-id').value = m.id;
$('#member-name').value = m.name;
$('#member-external').checked = !!m.external;
fillMemberPoint(m.meetingPoint);
$('#member-hascar').checked = m.hasCar;
$('#member-seats').value = m.seats || 4;
$('#member-always').checked = !!m.alwaysDrives;
fillRidesWith(m.id, m.ridesWith);
$('#member-seats-wrap').hidden = !m.hasCar;
$('#member-cancel').hidden = false;
$('#member-form-title').textContent = 'Mitglied bearbeiten';
$('#member-name').focus();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function remove(id) {
const m = members.find((x) => x.id === id);
if (!confirm(`${m.name} wirklich aus der Gruppe löschen?`)) return;
try {
await api('DELETE', `/members/${id}`);
await load();
} catch (err) {
showError(err);
}
}
function render() {
if (!$('#member-id').value) fillRidesWith();
const nameOf = (id) => members.find((m) => m.id === id)?.name;
const pointOf = (id) => points.find((p) => p.id === id)?.name;
$('#members-empty').hidden = members.length > 0;
$('#members-list').innerHTML = sortByName(members).map((m) => `
<li>
<span class="name">${esc(m.name)}${m.external ? ' <span class="badge">Extern</span>' : ''}</span>
${m.hasCar && pointOf(m.meetingPoint) ? `<span class="meta">ab ${esc(pointOf(m.meetingPoint))}</span>` : ''}
${nameOf(m.ridesWith) ? `<span class="meta">fährt gern bei ${esc(nameOf(m.ridesWith))} mit</span>` : ''}
<span class="badge">${m.hasCar ? `Auto · ${seatsLabel(m.seats)}${m.alwaysDrives ? ' · fährt immer' : ''}` : 'kein Auto'}</span>
<span class="actions">
<button class="link" data-edit="${m.id}">Bearbeiten</button>
<button class="link danger" data-del="${m.id}">Löschen</button>
</span>
</li>`).join('');
}
$('#members-list').addEventListener('click', (e) => {
if (e.target.dataset.edit) edit(e.target.dataset.edit);
if (e.target.dataset.del) remove(e.target.dataset.del);
});
$('#export-btn').addEventListener('click', () => {
const blob = new Blob([JSON.stringify(members, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'hcrb-gruppe.json';
a.click();
URL.revokeObjectURL(a.href);
});
$('#import-file').addEventListener('change', async (e) => {
const file = e.target.files[0];
e.target.value = '';
if (!file) return;
let data;
try {
data = JSON.parse(await file.text());
if (!Array.isArray(data)) throw new Error();
} catch {
alert('Die Datei konnte nicht gelesen werden.');
return;
}
if (!confirm(`${data.length} Mitglieder importieren? Die aktuelle Gruppe wird ersetzt.`)) return;
try {
await api('POST', '/members/import', data);
await load();
} catch (err) {
showError(err);
}
});
// ---------- Treffpunkte ----------
const pointForm = $('#point-form');
function resetPointForm() {
pointForm.reset();
$('#point-id').value = '';
$('#point-cancel').hidden = true;
$('#point-form-title').textContent = 'Treffpunkt hinzufügen';
}
$('#point-cancel').addEventListener('click', resetPointForm);
pointForm.addEventListener('submit', async (e) => {
e.preventDefault();
const body = { name: $('#point-name').value.trim(), address: $('#point-address').value.trim() };
const id = $('#point-id').value;
try {
if (id) await api('PUT', `/meeting-points/${id}`, body);
else await api('POST', '/meeting-points', body);
resetPointForm();
await loadPoints();
} catch (err) {
showError(err);
}
});
function renderPoints() {
$('#points-empty').hidden = points.length > 0;
$('#points-list').innerHTML = points.map((p) => `
<li>
<span class="name">${esc(p.name)}</span>
<span class="meta">${esc(p.address)}</span>
<span class="actions">
<button class="link" data-edit="${p.id}">Bearbeiten</button>
<button class="link danger" data-del="${p.id}">Löschen</button>
</span>
</li>`).join('');
}
$('#points-list').addEventListener('click', async (e) => {
const { edit: editId, del } = e.target.dataset;
if (editId) {
const p = points.find((x) => x.id === editId);
$('#point-id').value = p.id;
$('#point-name').value = p.name;
$('#point-address').value = p.address;
$('#point-cancel').hidden = false;
$('#point-form-title').textContent = 'Treffpunkt bearbeiten';
$('#point-name').focus();
}
if (del) {
const p = points.find((x) => x.id === del);
if (!confirm(`Treffpunkt „${p.name}“ wirklich löschen?`)) return;
try {
await api('DELETE', `/meeting-points/${del}`);
await loadPoints();
} catch (err) {
showError(err);
}
}
});
async function loadPoints() {
points = await api('GET', '/meeting-points');
renderPoints();
const editing = members.find((m) => m.id === $('#member-id').value);
fillMemberPoint(editing ? $('#member-point').value : undefined);
render();
}
async function load() {
members = await api('GET', '/members');
render();
}
load().then(loadPoints).catch(showError);
+182
View File
@@ -0,0 +1,182 @@
import {
$, api, esc, sortByName, showError, renderHeader,
toIso, fromIso, formatDate, getMe, setMe,
} from './common.js';
renderHeader('kalender');
let members = [];
let events = [];
let me = getMe();
const todayIso = toIso(new Date());
let selectedDay = todayIso;
let viewMonth = fromIso(todayIso);
viewMonth.setDate(1);
const memberName = (id) => members.find((m) => m.id === id)?.name ?? '(gelöscht)';
// ---------- "Ich bin" ----------
function renderMe() {
if (me && !members.some((m) => m.id === me)) me = null;
$('#me-select').innerHTML = '<option value="">– bitte auswählen –</option>' +
sortByName(members).map((m) =>
`<option value="${m.id}" ${m.id === me ? 'selected' : ''}>${esc(m.name)}</option>`).join('');
$('#me-hint').hidden = !!me;
$('#event-form button[type=submit]').disabled = !me;
}
$('#me-select').addEventListener('change', (e) => {
me = e.target.value || null;
setMe(me);
renderAll();
});
// ---------- Monatsansicht ----------
function renderCalendar() {
const title = viewMonth.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
$('#month-title').textContent = title;
// Raster beginnt am Montag vor (oder am) Monatsersten
const start = new Date(viewMonth);
start.setDate(1 - ((start.getDay() + 6) % 7));
const byDate = new Map();
for (const ev of events) {
if (!byDate.has(ev.date)) byDate.set(ev.date, []);
byDate.get(ev.date).push(ev);
}
let html = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'].map((d) => `<div class="dow">${d}</div>`).join('');
const day = new Date(start);
for (let i = 0; i < 42; i++) {
const iso = toIso(day);
const classes = ['cal-day'];
if (day.getMonth() !== viewMonth.getMonth()) classes.push('other');
if (iso === todayIso) classes.push('today');
if (iso === selectedDay) classes.push('selected');
const dayEvents = byDate.get(iso) || [];
html += `
<button class="${classes.join(' ')}" data-day="${iso}">
<span class="num">${day.getDate()}</span>
${dayEvents.map((ev) => `<span class="ev ${ev.attendees.includes(me) ? 'mine' : ''}"
title="${esc(ev.title)} (${ev.attendees.length} zugesagt)">${esc(ev.title)} · ${ev.attendees.length}</span>`).join('')}
</button>`;
day.setDate(day.getDate() + 1);
}
$('#calendar').innerHTML = html;
}
$('#calendar').addEventListener('click', (e) => {
const cell = e.target.closest('[data-day]');
if (!cell) return;
selectedDay = cell.dataset.day;
const d = fromIso(selectedDay);
if (d.getMonth() !== viewMonth.getMonth()) viewMonth = new Date(d.getFullYear(), d.getMonth(), 1);
renderAll();
$('#day-title').scrollIntoView({ behavior: 'smooth', block: 'start' });
});
function shiftMonth(delta) {
viewMonth = new Date(viewMonth.getFullYear(), viewMonth.getMonth() + delta, 1);
renderCalendar();
}
$('#prev-month').addEventListener('click', () => shiftMonth(-1));
$('#next-month').addEventListener('click', () => shiftMonth(1));
$('#today-btn').addEventListener('click', () => {
selectedDay = todayIso;
viewMonth = fromIso(todayIso);
viewMonth.setDate(1);
renderAll();
});
// ---------- Termine ----------
function eventHtml(ev) {
const going = ev.attendees.includes(me);
const attendees = sortByName(ev.attendees.map((id) => ({ id, name: memberName(id) })));
return `
<div class="event">
<h3>${esc(ev.title)}</h3>
<div class="when">${formatDate(ev.date, true)}${ev.createdBy ? ` · eingetragen von ${esc(memberName(ev.createdBy))}` : ''}</div>
${ev.note ? `<div class="note">${esc(ev.note)}</div>` : ''}
<div class="muted">${ev.attendees.length} zugesagt:</div>
<div class="chips">
${attendees.length
? attendees.map((a) => `<span class="chip ${a.id === me ? 'me' : ''}">${esc(a.name)}</span>`).join('')
: '<span class="muted">noch niemand</span>'}
</div>
<div class="row wrap">
${me ? (going
? `<button data-action="leave" data-id="${ev.id}">Absagen</button>`
: `<button class="primary" data-action="join" data-id="${ev.id}">Ich bin dabei</button>`) : ''}
<a class="button" href="/fahrt?termin=${ev.id}">Fahrt planen</a>
<button class="link danger" data-action="delete" data-id="${ev.id}">Löschen</button>
</div>
</div>`;
}
function renderDay() {
$('#day-title').textContent = fromIso(selectedDay).toLocaleDateString('de-DE', {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
});
const dayEvents = events.filter((e) => e.date === selectedDay);
$('#day-events').innerHTML = dayEvents.length
? dayEvents.map(eventHtml).join('')
: '<p class="muted">An diesem Tag ist noch kein Spieltag eingetragen.</p>';
}
function renderUpcoming() {
const upcoming = events.filter((e) => e.date >= todayIso);
$('#upcoming').innerHTML = upcoming.length
? upcoming.map(eventHtml).join('')
: '<p class="muted">Keine kommenden Spieltage.</p>';
}
document.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const { action, id } = btn.dataset;
try {
if (action === 'join') await api('PUT', `/events/${id}/attendees/${me}`);
if (action === 'leave') await api('DELETE', `/events/${id}/attendees/${me}`);
if (action === 'delete') {
const ev = events.find((x) => x.id === id);
if (!confirm(`Spieltag „${ev.title}“ am ${formatDate(ev.date)} wirklich löschen?`)) return;
await api('DELETE', `/events/${id}`);
}
await load();
} catch (err) {
showError(err);
}
});
$('#event-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api('POST', '/events', {
date: selectedDay,
title: $('#event-title').value,
note: $('#event-note').value,
createdBy: me,
});
e.target.reset();
await load();
} catch (err) {
showError(err);
}
});
// ---------- Laden ----------
function renderAll() {
renderMe();
renderCalendar();
renderDay();
renderUpcoming();
}
async function load() {
[members, events] = await Promise.all([api('GET', '/members'), api('GET', '/events')]);
renderAll();
}
load().catch(showError);
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kalender – HCRB</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<main>
<div class="card">
<label>Ich bin
<select id="me-select"></select>
</label>
<p id="me-hint" class="muted" hidden>Wähle deinen Namen aus, damit du Spieltage eintragen und zusagen kannst.
Du fehlst in der Liste? Dann trag dich unter <a href="/gruppe">Gruppe</a> ein.</p>
</div>
<div class="card">
<div class="cal-nav">
<button id="prev-month" aria-label="Vorheriger Monat">‹</button>
<h2 id="month-title"></h2>
<button id="next-month" aria-label="Nächster Monat">›</button>
</div>
<div class="row end"><button id="today-btn" class="small">Heute</button></div>
<div id="calendar" class="cal-grid"></div>
</div>
<div class="card">
<h2 id="day-title"></h2>
<div id="day-events"></div>
<form id="event-form">
<h3>Spieltag an diesem Tag eintragen</h3>
<label>Titel / Spielfeld
<input type="text" id="event-title" required maxlength="80" placeholder="z. B. Sonntagsspiel Waldfeld">
</label>
<label>Notiz (optional)
<textarea id="event-note" maxlength="500" placeholder="Uhrzeit, Link zum Event, Treffpunkt …"></textarea>
</label>
<button type="submit" class="primary">Eintragen</button>
</form>
</div>
<div class="card">
<h2>Kommende Spieltage</h2>
<div id="upcoming"></div>
</div>
</main>
<script type="module" src="/js/kalender.js"></script>
</body>
</html>