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}
/>