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) => ``).join('') + '' : ''; } // "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 `
  • ${m.hasCar ? ` ${m.alwaysDrives ? 'fährt immer' : ''}` : 'kein Auto'}
  • `; }).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 = '' + people.map((p) => ``).join(''); $('#guests-list').innerHTML = trip.guests.map((g) => `
  • ${esc(g.name)} Gast ${nameOf(g.ridesWith) ? `(gern bei ${esc(nameOf(g.ridesWith))})` : ''} ${g.hasCar ? `Auto · ${seatsLabel(g.seats)}` : 'kein Auto'}
  • `).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) => ``).join('') : ''; $('#fixed-driver').innerHTML = drivers.length ? drivers.map((d) => ``).join('') : ''; $('#fixed-form button').disabled = !passengers.length || !drivers.length; $('#fixed-list').innerHTML = [...trip.fixed].map(([passengerId, driverId]) => `
  • ${esc(byId.get(passengerId).name)} fährt bei ${esc(byId.get(driverId).name)}
  • `).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 ? ' Gast' : '') + (p.external ? ' Extern' : ''); 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 = '
    '; if (point) { const maps = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(point.address)}`; html += `
    Treffpunkt
    ${esc(point.name)} ${esc(point.address)}
    `; } if (meet) html += `
    Treffzeit
    ${esc(meet)} Uhr
    `; if (departure) html += `
    Abfahrt
    ${esc(departure)} Uhr
    `; return html + '
    '; } // 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 = '

    Bitte wähle mindestens eine Person aus.

    '; 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) => ``).join('') + ``; let html = heading ? `

    ${esc(heading)}

    ` : ''; html += '
    '; html += `

    ${participants.length} Personen · ${plan.cars.length} ${plan.cars.length === 1 ? 'Auto' : 'Autos'} · ${totalSeats} Sitzplätze

    `; html += '
    ' + plan.cars.map((car, i) => `

    Auto ${i + 1}: ${esc(car.driver.name)}${badges(car.driver)}

    ${car.passengers.length} / ${car.driver.seats} Plätze belegt
    ${car.passengers.length ? `
      ${car.passengers.map((p) => `
    1. ${esc(p.name)}${badges(p)}${trip.fixed.get(p.id) === car.driver.id ? ' fest' : ''}
    2. `).join('')}
    ` : '

    keine Mitfahrer

    '}
    `).join('') + '
    '; if (plan.unassigned.length) { const missing = plan.unassigned.length; html += `
    Nicht genug Plätze! Es fehlen ${seatsLabel(missing)}.
    Ohne Platz: ${plan.unassigned.map((p) => esc(p.name)).join(', ')}
    `; } html += `
    `; 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: ${esc(ev.title)} 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);