From 56c4f38ef0f9eec61435b796fb3d4d3557f987f1 Mon Sep 17 00:00:00 2001 From: guyg1474 <340572510+guyg1474@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:31:52 +0200 Subject: [PATCH] Reisen: Wohnort und nachvollziehbare Kilometer-Rechnung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 6 +- backend/app/db.py | 10 +++ backend/app/reisen.py | 35 +++++++++++ backend/tests/test_reisen.py | 10 +++ frontend/src/components/reisen/CitySearch.jsx | 20 +++--- .../src/components/reisen/TravelStats.jsx | 52 ++++++++++++--- frontend/src/components/reisen/WorldMap.jsx | 10 ++- frontend/src/components/reisen/route.js | 63 +++++++++++++++++++ frontend/src/pages/Reisen.jsx | 13 +++- frontend/src/styles/reisen.css | 13 ++++ 10 files changed, 210 insertions(+), 22 deletions(-) create mode 100644 frontend/src/components/reisen/route.js diff --git a/README.md b/README.md index 0f7ed83..90faa36 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/app/db.py b/backend/app/db.py index 4116aea..a64fd86 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -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) diff --git a/backend/app/reisen.py b/backend/app/reisen.py index 9fbdd38..860eec8 100644 --- a/backend/app/reisen.py +++ b/backend/app/reisen.py @@ -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}") diff --git a/backend/tests/test_reisen.py b/backend/tests/test_reisen.py index 5be9365..f5b3e2b 100644 --- a/backend/tests/test_reisen.py +++ b/backend/tests/test_reisen.py @@ -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 diff --git a/frontend/src/components/reisen/CitySearch.jsx b/frontend/src/components/reisen/CitySearch.jsx index aedcbb2..b8ac792 100644 --- a/frontend/src/components/reisen/CitySearch.jsx +++ b/frontend/src/components/reisen/CitySearch.jsx @@ -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 (
+ {mode === 'add' && (
+ )}
- +
{ 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} />
{show && ( -
    +
      {state === 'loading' && results.length === 0 &&
    • Suche …
    • } {state === 'error' &&
    • Suche gerade nicht erreichbar.
    • } {state === 'idle' && results.length === 0 &&
    • Keine Stadt gefunden.
    • } @@ -91,7 +95,7 @@ export default function CitySearch({ cities, onAdd, busy }) { return (
    • (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 (
      @@ -41,11 +45,39 @@ export default function TravelStats({ visited, cities, visits, wishes, onSelectC
      {fmtPct(pct(area, TOTAL_AREA))}der Landfläche ({num(Math.round(area))} km²)
      {cities.length}{cities.length === 1 ? 'Stadt' : 'Städte'} besucht · {wishes} auf der Wunschliste
      - {route.length > 1 ? `${num(Math.round(km))} km` : '–'} - {route.length > 1 ? `Luftlinie entlang ${visits.length} Besuchen (${days} Reisetage)` : 'Besuche mit Datum eintragen, um Kilometer zu sehen'} + {route.legs.length ? `${num(Math.round(route.km))} km` : '–'} + {route.legs.length ? `Luftlinie · ${summary}` : 'Besuche mit Datum eintragen, um Kilometer zu sehen'}
      +
      +

      Wohnort

      + {home ? ( +

      + ⌂ {home.name} {countryName(home.country)} + +

      + ) : ( +

      Mit Wohnort zählt jede Reise als Hin- und Rückweg ab zuhause.

      + )} + + {route.legs.length > 0 && ( +
      + Wie gerechnet? ({route.legs.length} Teilstrecken) +
        + {route.legs.map((l, i) => ( +
      1. + {l.from} → {l.to} + {l.kind} + {num(Math.round(l.km))} km +
      2. + ))} +
      + {!home &&

      Ohne Wohnort fehlen Hin- und Heimwege; die Reisen werden direkt nacheinander verbunden.

      } +
      + )} +
      +

      Kontinente

      {continents.map((c) => ( diff --git a/frontend/src/components/reisen/WorldMap.jsx b/frontend/src/components/reisen/WorldMap.jsx index 70301c1..d6d5e9e 100644 --- a/frontend/src/components/reisen/WorldMap.jsx +++ b/frontend/src/components/reisen/WorldMap.jsx @@ -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 {c.name} ))} + {home && (() => { + const p = projection([home.lon, home.lat]); + return p && ( + + Wohnort: {home.name} + + ); + })()} {[...otherCities, ...cities].map((city) => { const p = projection([city.lon, city.lat]); if (!p) return null; diff --git a/frontend/src/components/reisen/route.js b/frontend/src/components/reisen/route.js new file mode 100644 index 0000000..7be13a1 --- /dev/null +++ b/frontend/src/components/reisen/route.js @@ -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; +} diff --git a/frontend/src/pages/Reisen.jsx b/frontend/src/pages/Reisen.jsx index 6a7bbcd..8b8ceb1 100644 --- a/frontend/src/pages/Reisen.jsx +++ b/frontend/src/pages/Reisen.jsx @@ -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() { )} {data && tab === 'statistik' && ( - + run('PUT', '/api/reisen/wohnort', result)} + onRemoveHome={() => run('DELETE', '/api/reisen/wohnort')} + onSelectCity={showCity} + /> )} {data && tab === 'wunsch' && ( wishes.length ? ( diff --git a/frontend/src/styles/reisen.css b/frontend/src/styles/reisen.css index b021df3..7dd27a3 100644 --- a/frontend/src/styles/reisen.css +++ b/frontend/src/styles/reisen.css @@ -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; }