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

+4 -2
View File
@@ -199,8 +199,10 @@ Texte aus Wikipedia, Einwohner aus Wikidata, Orte über Open-Meteo/GeoNames).
Besuche sieht nur das eigene Konto; Fotos werden ohne Metadaten gespeichert.
- **Zeitleiste:** „Wann war ich wo?“ – alle Besuche nach Jahren, mit Fotos (Klick vergrößert).
- **Statistik:** Anteil der Welt (Länder und Gebiete der Länderdaten) und der Landfläche, je Kontinent,
nördlichster/südlichster/östlichster/westlichster Ort und die Kilometer (Luftlinie) entlang der
Besuche in zeitlicher Reihenfolge.
nördlichster/südlichster/östlichster/westlichster Ort und die Kilometer (Luftlinie). Mit **Wohnort**
(im Tab „Statistik“ festlegen, erscheint als ⌂ auf der Karte) zählt jede Reise mit Hin- und Heimweg;
Reisen mit höchstens einem Tag Abstand gelten als Rundreise, Besuche während eines längeren Aufenthalts
als Ausflug hin und zurück. „Wie gerechnet?“ listet alle Teilstrecken. Reisetage zählen jeden Tag einmal.
Der Schalter **Städte anderer zeigen** (über der Karte) blendet die Städte der übrigen Konten ein – je Person in einer eigenen Farbe,
einzelne Personen lassen sich über ihre Schaltfläche aus- und einblenden (gemerkt pro Browser). Fremde
+10
View File
@@ -232,6 +232,16 @@ CREATE TABLE IF NOT EXISTS travel_visits (
created_at TEXT NOT NULL
);
-- Wohnort pro Konto: Start und Ziel jeder Reise für die Kilometer-Statistik
CREATE TABLE IF NOT EXISTS travel_home (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
geoname_id INTEGER NOT NULL,
name TEXT NOT NULL,
country TEXT NOT NULL,
lat REAL NOT NULL,
lon REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS travel_visit_photos (
visit_id INTEGER NOT NULL REFERENCES travel_visits(id) ON DELETE CASCADE,
file TEXT NOT NULL, -- Datei in data/uploads (ohne Metadaten gespeichert)
+35
View File
@@ -90,9 +90,11 @@ def _visits(conn, user_id: int) -> list:
def _state(conn, user_id: int) -> dict:
cities = conn.execute("SELECT * FROM travel_cities WHERE user_id = ? ORDER BY name", (user_id,)).fetchall()
countries = conn.execute("SELECT code FROM travel_countries WHERE user_id = ? ORDER BY code", (user_id,)).fetchall()
home = conn.execute("SELECT * FROM travel_home WHERE user_id = ?", (user_id,)).fetchone()
return {
"cities": [_city(r) for r in cities],
"countries": [r["code"] for r in countries],
"home": {k: home[k] for k in ("name", "country", "lat", "lon")} | {"geonameId": home["geoname_id"]} if home else None,
"visits": _visits(conn, user_id),
"others": _others(conn, user_id),
}
@@ -178,6 +180,39 @@ def update_city(city_id: int, data: dict = Body(...), user: dict = Depends(curre
return _state(conn, user["id"])
# ---------- Wohnort ----------
@router.put("/api/reisen/wohnort")
def set_home(data: dict = Body(...), user: dict = Depends(current_user)):
"""Wohnort festlegen (Ergebnis der Städtesuche); nur für die eigene Kilometer-Statistik."""
geoname_id = data.get("geonameId")
if isinstance(geoname_id, bool) or not isinstance(geoname_id, int) or geoname_id <= 0:
raise ValidationError("Ungültige Stadt.")
country = data.get("country")
if not isinstance(country, str) or not re.fullmatch(r"[A-Z]{2}", country):
raise ValidationError("Ungültiges Land.")
values = (
user["id"], geoname_id, clean_text(data.get("name"), "Name", 100), country,
_number(data.get("lat"), "Breitengrad", -90, 90), _number(data.get("lon"), "Längengrad", -180, 180),
)
with transaction() as conn:
conn.execute(
"""INSERT INTO travel_home (user_id, geoname_id, name, country, lat, lon) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id) DO UPDATE SET geoname_id = excluded.geoname_id, name = excluded.name,
country = excluded.country, lat = excluded.lat, lon = excluded.lon""",
values,
)
return _state(conn, user["id"])
@router.delete("/api/reisen/wohnort")
def delete_home(user: dict = Depends(current_user)):
with transaction() as conn:
conn.execute("DELETE FROM travel_home WHERE user_id = ?", (user["id"],))
return _state(conn, user["id"])
# ---------- Besuche: Zeitraum, Begleitung, Notizen, Fotos ----------
DATE = re.compile(r"\d{4}-\d{2}-\d{2}")
+10
View File
@@ -112,3 +112,13 @@ def test_reisefotos_ueberleben_aufraeumen(login):
os.utime(DATA_DIR / "uploads" / f, (old, old))
projects._cleanup_orphans()
assert (DATA_DIR / "uploads" / f).exists()
def test_wohnort_setzen_und_entfernen(login):
anna, ben = login("anna"), login("ben")
berlin = {k: BERLIN[k] for k in ("geonameId", "name", "country", "lat", "lon")}
data = anna.put("/api/reisen/wohnort", json=berlin).json()
assert data["home"] == berlin
assert ben.get("/api/reisen").json()["home"] is None # nur das eigene Konto
assert anna.put("/api/reisen/wohnort", json={**berlin, "lat": 99}).status_code == 400
assert anna.delete("/api/reisen/wohnort").json()["home"] is None
+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; }