Reisen: Wohnort und nachvollziehbare Kilometer-Rechnung

- Wohnort pro Konto (Tabelle travel_home), im Tab "Statistik" über die
  Städtesuche festlegen, auf der Karte als ⌂
- Kilometer (route.js): jede Reise mit Hin- und Heimweg ab Wohnort;
  Reisen mit höchstens einem Tag Abstand als Rundreise; Besuche während
  eines längeren Aufenthalts als Ausflug hin und zurück; ohne Wohnort
  werden Reisen direkt verbunden
- "Wie gerechnet?" listet alle Teilstrecken; Reisetage zählen jeden Tag
  nur einmal (Ausflüge im Aufenthalt nicht doppelt)
- Wohnort-Suche mit eigenen IDs (zwei Suchfelder auf der Seite)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 20:31:52 +02:00
1 parent fdbb31065f
commit 56c4f38ef0
10 files changed
+210 -22

No files matched your search

+12 -8
View File
@@ -4,8 +4,10 @@ import { BY_CODE, people } from './laender.js';
// Stadt suchen (Open-Meteo über den Server) und hinzufügen; schon eingetragene sind markiert
export default function CitySearch({ cities, onAdd, busy }) {
// mode: 'add' (Stadt besucht/Wunschliste hinzufügen) oder 'home' (Wohnort wählen)
export default function CitySearch({ cities, onAdd, busy, mode = 'add' }) {
const [wish, setWish] = useState(false); // Besucht oder Wunschliste
const ids = mode === 'home' ? 'home-search' : 'city-search'; // eindeutig, auch wenn beide Suchen auf der Seite sind
const [q, setQ] = useState('');
const [results, setResults] = useState([]);
const [open, setOpen] = useState(false);
@@ -56,33 +58,35 @@ export default function CitySearch({ cities, onAdd, busy }) {
return (
<div className="city-search-row">
{mode === 'add' && (
<div className="sort-toggle" role="group" aria-label="Hinzufügen als">
<button type="button" aria-pressed={!wish} onClick={() => setWish(false)}>Besucht</button>
<button type="button" aria-pressed={wish} onClick={() => setWish(true)}>☆ Wunschliste</button>
</div>
)}
<div className="city-search combo">
<label className="sr-only" htmlFor="city-search">Stadt hinzufügen</label>
<label className="sr-only" htmlFor={ids}>Stadt hinzufügen</label>
<div className="prompt-input">
<input
id="city-search"
id={ids}
value={q}
onChange={(e) => { setQ(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
onKeyDown={onKeyDown}
placeholder={wish ? 'Stadt auf die Wunschliste setzen …' : 'Stadt hinzufügen, in der du schon warst …'}
placeholder={mode === 'home' ? 'Wohnort suchen …' : wish ? 'Stadt auf die Wunschliste setzen …' : 'Stadt hinzufügen, in der du schon warst …'}
role="combobox"
aria-autocomplete="list"
aria-expanded={show && results.length > 0}
aria-controls="city-results"
aria-activedescendant={show && active >= 0 ? `city-result-${active}` : undefined}
aria-controls={`${ids}-results`}
aria-activedescendant={show && active >= 0 ? `${ids}-result-${active}` : undefined}
autoComplete="off"
spellCheck={false}
disabled={busy}
/>
</div>
{show && (
<ul className="combo-list" id="city-results" role="listbox">
<ul className="combo-list" id={`${ids}-results`} role="listbox">
{state === 'loading' && results.length === 0 && <li className="combo-info">Suche …</li>}
{state === 'error' && <li className="combo-info">Suche gerade nicht erreichbar.</li>}
{state === 'idle' && results.length === 0 && <li className="combo-info">Keine Stadt gefunden.</li>}
@@ -91,7 +95,7 @@ export default function CitySearch({ cities, onAdd, busy }) {
return (
<li
key={r.geonameId}
id={`city-result-${i}`}
id={`${ids}-result-${i}`}
role="option"
aria-selected={i === active}
aria-disabled={added || undefined}
+42 -10
View File
@@ -1,5 +1,7 @@
import LAENDER from '../../data/laender.json';
import { BY_CODE, CONTINENTS, continentOf, countryName, distanceKm, num } from './laender.js';
import CitySearch from './CitySearch.jsx';
import { BY_CODE, CONTINENTS, continentOf, countryName, num } from './laender.js';
import { travelDays, travelRoute } from './route.js';
const pct = (part, total) => (total ? (part / total) * 100 : 0);
const fmtPct = (p) => `${p.toLocaleString('de-DE', { maximumFractionDigits: p < 10 ? 1 : 0 })} %`;
@@ -9,7 +11,7 @@ const TOTAL_AREA = LAENDER.reduce((s, c) => s + (c.area || 0), 0);
* Statistik: Länder (Welt und je Kontinent), Fläche, äußerste Punkte, Kilometer entlang der Zeitleiste.
* Grundlage für "Welt": alle Länder und Gebiete der Länderdaten (world-countries).
*/
export default function TravelStats({ visited, cities, visits, wishes, onSelectCity }) {
export default function TravelStats({ visited, cities, visits, wishes, home, onSetHome, onRemoveHome, busy, onSelectCity }) {
const codes = [...visited].filter((c) => BY_CODE.has(c));
const area = codes.reduce((s, c) => s + (BY_CODE.get(c).area || 0), 0);
const continents = CONTINENTS.map((name) => {
@@ -27,12 +29,14 @@ export default function TravelStats({ visited, cities, visits, wishes, onSelectC
['Westlichster Ort', extreme((c) => -c.lon), (c) => `${Math.abs(c.lon).toFixed(1).replace('.', ',')}° ${c.lon < 0 ? 'W' : 'O'}`],
];
// Kilometer: Besuche in zeitlicher Reihenfolge, Luftlinie von Ort zu Ort
const byId = new Map(cities.map((c) => [c.id, c]));
const route = [...visits].sort((a, b) => a.dateFrom.localeCompare(b.dateFrom)).map((v) => byId.get(v.cityId)).filter(Boolean);
let km = 0;
for (let i = 1; i < route.length; i += 1) if (route[i].id !== route[i - 1].id) km += distanceKm(route[i - 1], route[i]);
const days = visits.reduce((s, v) => s + 1 + (v.dateTo ? Math.round((new Date(v.dateTo) - new Date(v.dateFrom)) / 864e5) : 0), 0);
// Kilometer: Reisen ab Wohnort, Rundreisen und Ausflüge (siehe route.js)
const route = travelRoute(visits, cities, home);
const days = travelDays(visits);
const summary = [
`${route.trips} ${route.trips === 1 ? 'Reise' : 'Reisen'}${home ? ` ab ${home.name}` : ''}`,
route.excursions && `${route.excursions} ${route.excursions === 1 ? 'Ausflug' : 'Ausflüge'}`,
`${days} Reisetage`,
].filter(Boolean).join(' · ');
return (
<div className="travel-stats">
@@ -41,11 +45,39 @@ export default function TravelStats({ visited, cities, visits, wishes, onSelectC
<div className="stat-tile"><strong>{fmtPct(pct(area, TOTAL_AREA))}</strong><span>der Landfläche ({num(Math.round(area))} km²)</span></div>
<div className="stat-tile"><strong>{cities.length}</strong><span>{cities.length === 1 ? 'Stadt' : 'Städte'} besucht · {wishes} auf der Wunschliste</span></div>
<div className="stat-tile">
<strong>{route.length > 1 ? `${num(Math.round(km))} km` : '–'}</strong>
<span>{route.length > 1 ? `Luftlinie entlang ${visits.length} Besuchen (${days} Reisetage)` : 'Besuche mit Datum eintragen, um Kilometer zu sehen'}</span>
<strong>{route.legs.length ? `${num(Math.round(route.km))} km` : '–'}</strong>
<span>{route.legs.length ? `Luftlinie · ${summary}` : 'Besuche mit Datum eintragen, um Kilometer zu sehen'}</span>
</div>
</div>
<section className="stat-home" aria-labelledby="stat-wohnort">
<h2 className="section-title" id="stat-wohnort">Wohnort</h2>
{home ? (
<p className="stat-home-current">
⌂ <strong>{home.name}</strong> <small>{countryName(home.country)}</small>
<button className="link-btn danger" type="button" disabled={busy} onClick={onRemoveHome}>Entfernen</button>
</p>
) : (
<p className="hint">Mit Wohnort zählt jede Reise als Hin- und Rückweg ab zuhause.</p>
)}
<CitySearch mode="home" cities={[]} onAdd={onSetHome} busy={busy} />
{route.legs.length > 0 && (
<details className="stat-route">
<summary>Wie gerechnet? ({route.legs.length} Teilstrecken)</summary>
<ol>
{route.legs.map((l, i) => (
<li key={i}>
<span>{l.from} → {l.to}</span>
<small>{l.kind}</small>
<strong>{num(Math.round(l.km))} km</strong>
</li>
))}
</ol>
{!home && <p className="hint">Ohne Wohnort fehlen Hin- und Heimwege; die Reisen werden direkt nacheinander verbunden.</p>}
</details>
)}
</section>
<section className="stat-continents" aria-labelledby="stat-kontinente">
<h2 className="section-title" id="stat-kontinente">Kontinente</h2>
{continents.map((c) => (
+9 -1
View File
@@ -32,7 +32,7 @@ function mainPart(feature) {
* Über `ref`: focusCountry(key, offset), focusCity(city, offset), zoomBy(f), reset().
* offset: Ziel nach rechts oben schieben, weil unten links der Info-Kasten liegt.
*/
export default function WorldMap({ ref, countries, visited, cities, otherCities = [], selected, onSelect }) {
export default function WorldMap({ ref, countries, visited, cities, otherCities = [], home, selected, onSelect }) {
const box = useRef(null);
const svgRef = useRef(null);
const layer = useRef(null);
@@ -133,6 +133,14 @@ export default function WorldMap({ ref, countries, visited, cities, otherCities
<title>{c.name}</title>
</path>
))}
{home && (() => {
const p = projection([home.lon, home.lat]);
return p && (
<g transform={`translate(${p[0]},${p[1]})`}>
<g className="map-home"><title>Wohnort: {home.name}</title><path d="M0 -6 L6 0 L4 0 L4 5 L-4 5 L-4 0 L-6 0 Z" /></g>
</g>
);
})()}
{[...otherCities, ...cities].map((city) => {
const p = projection([city.lon, city.lat]);
if (!p) return null;
+63
View File
@@ -0,0 +1,63 @@
// Reiseroute für die Kilometer-Statistik, aus den eingetragenen Besuchen (Luftlinie):
// - Reise: Wohnort → Ziel → Wohnort. Beginnt die nächste Reise höchstens einen Tag nach dem Ende der vorigen,
// ist es eine Rundreise (Wohnort → A → B → Wohnort).
// - Ausflug: Besuch, der während eines längeren Aufenthalts beginnt (z. B. David während San José) –
// zählt als Hin- und Rückweg zum Aufenthaltsort.
// Ohne Wohnort fehlen Hin- und Heimwege; die Reisen werden dann direkt nacheinander verbunden.
import { distanceKm } from './laender.js';
const day = (d) => Math.round(Date.parse(`${d}T12:00:00Z`) / 864e5);
export function travelRoute(visits, cities, home) {
const byId = new Map(cities.map((c) => [c.id, c]));
const items = visits
.map((v) => ({ ...v, city: byId.get(v.cityId), to: v.dateTo || v.dateFrom }))
.filter((v) => v.city)
.sort((a, b) => a.dateFrom.localeCompare(b.dateFrom) || b.to.localeCompare(a.to));
// Ausflüge ihrem Aufenthalt zuordnen
const trips = [];
for (const v of items) {
const base = trips.at(-1)?.visit;
if (base && v.dateFrom >= base.dateFrom && v.dateFrom <= base.to) trips.at(-1).excursions.push(v);
else trips.push({ visit: v, excursions: [] });
}
const legs = [];
const leg = (from, to, kind) => {
if (!from || !to || (from.lat === to.lat && from.lon === to.lon)) return;
legs.push({ from: from.name, to: to.name, km: distanceKm(from, to), kind });
};
let pos = null;
trips.forEach((t, i) => {
const chained = i > 0 && day(t.visit.dateFrom) - day(trips[i - 1].visit.to) <= 1;
if (home && !chained) {
leg(pos, home, 'Heimweg');
pos = home;
}
// Ohne Wohnort werden getrennte Reisen direkt verbunden – das ist dann eine Weiterreise, kein Hinweg
leg(pos, t.visit.city, chained || !home ? 'Weiterreise' : 'Hinweg');
pos = t.visit.city;
for (const ex of t.excursions) {
leg(pos, ex.city, 'Ausflug');
leg(ex.city, pos, 'Ausflug zurück');
}
});
if (home && trips.length) leg(pos, home, 'Heimweg');
return {
legs,
km: legs.reduce((s, l) => s + l.km, 0),
trips: trips.length,
excursions: trips.reduce((s, t) => s + t.excursions.length, 0),
};
}
// Reisetage: jeder Kalendertag nur einmal (Ausflüge während eines Aufenthalts zählen nicht doppelt)
export function travelDays(visits) {
const days = new Set();
for (const v of visits) {
for (let d = day(v.dateFrom); d <= day(v.dateTo || v.dateFrom); d += 1) days.add(d);
}
return days.size;
}
+12 -1
View File
@@ -205,6 +205,7 @@ export default function Reisen() {
visited={visited}
cities={own}
otherCities={otherCities}
home={data?.home}
selected={selected}
onSelect={(sel) => select(sel)}
/>
@@ -253,7 +254,17 @@ export default function Reisen() {
<Timeline visits={visits} cities={own} onSelectCity={showCity} onEdit={openVisit} onPhoto={setZoom} />
)}
{data && tab === 'statistik' && (
<TravelStats visited={visited} cities={cities} visits={visits} wishes={wishes.length} onSelectCity={showCity} />
<TravelStats
visited={visited}
cities={cities}
visits={visits}
wishes={wishes.length}
home={data.home}
busy={busy}
onSetHome={(result) => run('PUT', '/api/reisen/wohnort', result)}
onRemoveHome={() => run('DELETE', '/api/reisen/wohnort')}
onSelectCity={showCity}
/>
)}
{data && tab === 'wunsch' && (
wishes.length ? (
+13
View File
@@ -236,3 +236,16 @@
.link-picker ul.visit-photos { display: flex; flex-direction: row; flex-wrap: wrap; gap: 8px; max-height: none; }
.link-picker ul.visit-photos li { width: auto; padding: 0; border: 0; }
.visit-hint { margin: -8px 0 0; }
/* Wohnort */
.map-home { transform: scale(var(--inv)); pointer-events: none; }
.map-home path { fill: var(--bright); stroke: var(--bg); stroke-width: 1.5; paint-order: stroke; }
.stat-home { display: grid; gap: 10px; max-width: 760px; }
.stat-home-current { display: flex; align-items: baseline; gap: 10px; margin: 0; }
.stat-home-current small { color: var(--muted); }
.stat-home .city-search-row { margin: 0; }
.stat-route summary { cursor: pointer; color: var(--accent); }
.stat-route ol { margin: 8px 0 0; padding-left: 1.6em; display: grid; gap: 2px; }
.stat-route li span { margin-right: 8px; }
.stat-route li small { color: var(--muted); margin-right: 8px; }
.stat-route li strong { font-variant-numeric: tabular-nums; }