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 && ( -