Files
hcrb/private/js/fahrt.js
T
2026-10-10 13:54:33 +02:00

520 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);