diff --git a/README.md b/README.md index 90faa36..6ecc962 100644 --- a/README.md +++ b/README.md @@ -199,10 +199,16 @@ 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). 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. + nördlichster/südlichster/östlichster/westlichster Ort und die Kilometer (Luftlinie): + - **Wohnorte** (Tab „Statistik“, mit optionalem „seit“-Datum): Jede Reise startet und endet beim + Wohnort, der damals galt; ein Wechsel zählt als Umzug. Für einen längeren Auslandsaufenthalt im + Besuch „⌂ In dieser Zeit hier gewohnt“ wählen – das legt beide Wechsel an. Der aktuelle Wohnort + steht als ⌂ auf der Karte. + - **Anreise** je Besuch: „von zuhause“ oder „vom vorherigen Ort weitergereist“ (Rundreise: + Wohnort → A → B → Wohnort), sonst automatisch (höchstens 1 Tag Abstand = Weiterreise). + - Besuche während eines längeren Aufenthalts zählen als Ausflug hin und zurück. + - „Wie gerechnet?“ listet alle Teilstrecken. Reisetage zählen jeden Tag einmal, Tage am damaligen + Wohnort nicht. 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 a64fd86..05d77e4 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -229,17 +229,22 @@ CREATE TABLE IF NOT EXISTS travel_visits ( date_to TEXT, companions TEXT NOT NULL DEFAULT '', notes TEXT NOT NULL DEFAULT '', + continued INTEGER, -- Anreise: 1 = vom vorherigen Ort weitergereist, 0 = von zuhause, NULL = automatisch 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, +-- Wohnorte pro Konto mit "seit"-Datum (NULL = von Anfang an): Start und Ziel jeder Reise in der +-- Kilometer-Statistik; ein Wechsel (auch ein längerer Auslandsaufenthalt) zählt als Umzug +CREATE TABLE IF NOT EXISTS travel_homes ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL 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 + lon REAL NOT NULL, + since TEXT, -- JJJJ-MM-TT + UNIQUE (user_id, since) ); CREATE TABLE IF NOT EXISTS travel_visit_photos ( diff --git a/backend/app/migrations.py b/backend/app/migrations.py index 6258fc0..0f4c253 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -256,10 +256,25 @@ def _v4_wunschliste(conn) -> None: _add_column(conn, "travel_cities", "wish", "INTEGER NOT NULL DEFAULT 0") +# ---------- Schritt 5: Wohnorte mit Datum, Anreise je Besuch ---------- + + +def _v5_wohnorte(conn) -> None: + """Einzelner Wohnort (Zwischenstand) → Liste von Wohnorten mit "seit"-Datum; Besuche bekommen die Anreise.""" + if conn.execute("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'travel_home'").fetchone(): + conn.execute( + """INSERT OR IGNORE INTO travel_homes (user_id, geoname_id, name, country, lat, lon, since) + SELECT user_id, geoname_id, name, country, lat, lon, NULL FROM travel_home""" + ) + conn.execute("DROP TABLE travel_home") + _add_column(conn, "travel_visits", "continued", "INTEGER") + + # Reihenfolge = Versionsnummer (Index + 1). Nur anhängen, nie umsortieren oder entfernen. STEPS = [ _v1_altbestand, _v2_datenschutz, _v3_kategorien, _v4_wunschliste, + _v5_wohnorte, ] diff --git a/backend/app/reisen.py b/backend/app/reisen.py index 860eec8..06db7f5 100644 --- a/backend/app/reisen.py +++ b/backend/app/reisen.py @@ -82,6 +82,7 @@ def _visits(conn, user_id: int) -> list: { "id": v["id"], "cityId": v["city_id"], "dateFrom": v["date_from"], "dateTo": v["date_to"], "companions": v["companions"], "notes": v["notes"], "photos": photos.get(v["id"], []), + "continued": None if v["continued"] is None else bool(v["continued"]), } for v in rows ] @@ -90,11 +91,17 @@ 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() + homes = conn.execute( + "SELECT * FROM travel_homes WHERE user_id = ? ORDER BY since IS NOT NULL, since", (user_id,) + ).fetchall() 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, + "homes": [ + {"id": h["id"], "geonameId": h["geoname_id"], "name": h["name"], "country": h["country"], + "lat": h["lat"], "lon": h["lon"], "since": h["since"]} + for h in homes + ], "visits": _visits(conn, user_id), "others": _others(conn, user_id), } @@ -180,36 +187,77 @@ def update_city(city_id: int, data: dict = Body(...), user: dict = Depends(curre return _state(conn, user["id"]) -# ---------- Wohnort ---------- +# ---------- Wohnorte (mit "seit"-Datum) ---------- + +MAX_HOMES = 50 -@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.""" +def _add_home(conn, user_id: int, city: dict, since) -> None: + """Wohnort ab Datum eintragen; gleiches Datum wird ersetzt.""" + conn.execute("DELETE FROM travel_homes WHERE user_id = ? AND since IS ?", (user_id, since)) + if conn.execute("SELECT COUNT(*) FROM travel_homes WHERE user_id = ?", (user_id,)).fetchone()[0] >= MAX_HOMES: + raise ValidationError(f"Maximal {MAX_HOMES} Wohnorte.") + conn.execute( + "INSERT INTO travel_homes (user_id, geoname_id, name, country, lat, lon, since) VALUES (?, ?, ?, ?, ?, ?, ?)", + (user_id, city["geoname_id"], city["name"], city["country"], city["lat"], city["lon"], since), + ) + + +def _home_at(conn, user_id: int, day: str): + """Wohnort, der an diesem Tag galt (der jüngste mit since <= Tag; ohne Datum = von Anfang an).""" + return conn.execute( + """SELECT * FROM travel_homes WHERE user_id = ? AND (since IS NULL OR since <= ?) + ORDER BY since IS NOT NULL DESC, since DESC LIMIT 1""", + (user_id, day), + ).fetchone() + + +@router.post("/api/reisen/wohnorte", status_code=201) +def add_home(data: dict = Body(...), user: dict = Depends(current_user)): + """Wohnort festlegen ({Städtesuche-Ergebnis, since}); since leer = von Anfang an. Gleiches Datum ersetzt.""" 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), - ) + city = { + "geoname_id": geoname_id, "name": clean_text(data.get("name"), "Name", 100), "country": country, + "lat": _number(data.get("lat"), "Breitengrad", -90, 90), "lon": _number(data.get("lon"), "Längengrad", -180, 180), + } + since = _date(data.get("since"), "Seit", False) 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, - ) + _add_home(conn, user["id"], city, since) return _state(conn, user["id"]) -@router.delete("/api/reisen/wohnort") -def delete_home(user: dict = Depends(current_user)): +@router.delete("/api/reisen/wohnorte/{home_id}") +def delete_home(home_id: int, user: dict = Depends(current_user)): with transaction() as conn: - conn.execute("DELETE FROM travel_home WHERE user_id = ?", (user["id"],)) + cur = conn.execute("DELETE FROM travel_homes WHERE id = ? AND user_id = ?", (home_id, user["id"])) + if not cur.rowcount: + raise HTTPException(404, "Wohnort nicht gefunden.") + return _state(conn, user["id"]) + + +@router.post("/api/reisen/besuche/{visit_id}/gewohnt") +def lived_there(visit_id: int, user: dict = Depends(current_user)): + """Während dieses Besuchs hier gewohnt: Wohnort ab "Von", danach wieder der vorherige Wohnort ab "Bis" + 1 Tag.""" + with transaction() as conn: + visit = _own_visit(conn, visit_id, user) + if not visit["date_to"]: + raise ValidationError("Für einen Aufenthalt bitte erst „Bis“ eintragen.") + city = _own_city(conn, visit["city_id"], user) + before = _home_at(conn, user["id"], (datetime.date.fromisoformat(visit["date_from"]) - datetime.timedelta(days=1)).isoformat()) + after = (datetime.date.fromisoformat(visit["date_to"]) + datetime.timedelta(days=1)).isoformat() + # Spätere Wechsel innerhalb des Aufenthalts wären widersprüchlich + conn.execute( + "DELETE FROM travel_homes WHERE user_id = ? AND since > ? AND since < ?", (user["id"], visit["date_from"], after) + ) + later = conn.execute("SELECT 1 FROM travel_homes WHERE user_id = ? AND since = ?", (user["id"], after)).fetchone() + _add_home(conn, user["id"], dict(city), visit["date_from"]) + if before and not later and before["geoname_id"] != city["geoname_id"]: + _add_home(conn, user["id"], dict(before), after) return _state(conn, user["id"]) @@ -239,7 +287,8 @@ def _date(value, field: str, required: bool): return value -def _visit_fields(data: dict) -> tuple: +def _visit_fields(data: dict, known_photos=()) -> tuple: + """known_photos: schon gespeicherte Fotos des Besuchs – fehlt deren Datei, bleibt der Besuch trotzdem bearbeitbar.""" date_from = _date(data.get("dateFrom"), "Von", True) date_to = _date(data.get("dateTo"), "Bis", False) if date_to and date_to < date_from: @@ -250,9 +299,12 @@ def _visit_fields(data: dict) -> tuple: if not isinstance(photos, list) or len(photos) > MAX_PHOTOS or not all(isinstance(p, str) and UPLOAD_NAME.fullmatch(p) for p in photos): raise ValidationError(f"Ungültige Fotos (max. {MAX_PHOTOS}).") photos = list(dict.fromkeys(photos)) - if any(not (UPLOAD_DIR / p).exists() for p in photos): + if any(p not in known_photos and not (UPLOAD_DIR / p).exists() for p in photos): raise ValidationError("Ein Foto wurde nicht gefunden – bitte erneut hochladen.") - return date_from, date_to, companions, notes, photos + continued = data.get("continued") + if continued is not None and not isinstance(continued, bool): + raise ValidationError("Ungültige Anreise.") + return date_from, date_to, companions, notes, photos, continued def _save_photos(conn, visit_id: int, photos: list) -> list: @@ -279,7 +331,7 @@ def _own_visit(conn, visit_id: int, user: dict): @router.post("/api/reisen/staedte/{city_id}/besuche", status_code=201) def add_visit(city_id: int, data: dict = Body(...), user: dict = Depends(current_user)): """Besuch eintragen; eine Stadt von der Wunschliste gilt damit als besucht.""" - date_from, date_to, companions, notes, photos = _visit_fields(data) + date_from, date_to, companions, notes, photos, continued = _visit_fields(data) with transaction() as conn: _own_city(conn, city_id, user) if conn.execute( @@ -287,8 +339,9 @@ def add_visit(city_id: int, data: dict = Body(...), user: dict = Depends(current ).fetchone()[0] >= MAX_VISITS: raise ValidationError(f"Maximal {MAX_VISITS} Besuche.") visit_id = conn.execute( - "INSERT INTO travel_visits (city_id, date_from, date_to, companions, notes, created_at) VALUES (?, ?, ?, ?, ?, ?)", - (city_id, date_from, date_to, companions, notes, now_iso()), + """INSERT INTO travel_visits (city_id, date_from, date_to, companions, notes, continued, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (city_id, date_from, date_to, companions, notes, None if continued is None else int(continued), now_iso()), ).lastrowid _save_photos(conn, visit_id, photos) conn.execute("UPDATE travel_cities SET wish = 0 WHERE id = ?", (city_id,)) @@ -297,12 +350,13 @@ def add_visit(city_id: int, data: dict = Body(...), user: dict = Depends(current @router.put("/api/reisen/besuche/{visit_id}") def update_visit(visit_id: int, data: dict = Body(...), user: dict = Depends(current_user)): - date_from, date_to, companions, notes, photos = _visit_fields(data) with transaction() as conn: _own_visit(conn, visit_id, user) + known = {r["file"] for r in conn.execute("SELECT file FROM travel_visit_photos WHERE visit_id = ?", (visit_id,))} + date_from, date_to, companions, notes, photos, continued = _visit_fields(data, known) conn.execute( - "UPDATE travel_visits SET date_from = ?, date_to = ?, companions = ?, notes = ? WHERE id = ?", - (date_from, date_to, companions, notes, visit_id), + "UPDATE travel_visits SET date_from = ?, date_to = ?, companions = ?, notes = ?, continued = ? WHERE id = ?", + (date_from, date_to, companions, notes, None if continued is None else int(continued), visit_id), ) remove_unused(conn, _save_photos(conn, visit_id, photos)) return _state(conn, user["id"]) diff --git a/backend/tests/test_migrationen.py b/backend/tests/test_migrationen.py index dc1fbaf..6d27541 100644 --- a/backend/tests/test_migrationen.py +++ b/backend/tests/test_migrationen.py @@ -172,3 +172,16 @@ def test_uralte_aufgaben_umbau_laesst_neue_tabellen_heil(old_db): conn.execute("INSERT INTO kanban_checklist (task_id, text, done, position) VALUES (1, 'x', 0, 0)") sql = conn.execute("SELECT sql FROM sqlite_master WHERE name = 'kanban_checklist'").fetchone()[0] assert "kanban_tasks_alt" not in sql + + +def test_einzelner_wohnort_wird_liste(conn, login): + """Zwischenstand mit travel_home (ein Wohnort ohne Datum) → travel_homes, Besuche bekommen "continued".""" + anna = login("anna") + with conn: + conn.execute("""CREATE TABLE travel_home (user_id INTEGER PRIMARY KEY, geoname_id INTEGER NOT NULL, name TEXT NOT NULL, + country TEXT NOT NULL, lat REAL NOT NULL, lon REAL NOT NULL)""") + conn.execute("INSERT INTO travel_home VALUES (?, 2950159, 'Berlin', 'DE', 52.5, 13.4)", (anna.user["id"],)) + conn.execute("PRAGMA user_version = 4") + db.init() + assert [(h["name"], h["since"]) for h in anna.get("/api/reisen").json()["homes"]] == [("Berlin", None)] + assert not conn.execute("SELECT 1 FROM sqlite_master WHERE name = 'travel_home'").fetchone() diff --git a/backend/tests/test_reisen.py b/backend/tests/test_reisen.py index f5b3e2b..852d284 100644 --- a/backend/tests/test_reisen.py +++ b/backend/tests/test_reisen.py @@ -114,11 +114,54 @@ def test_reisefotos_ueberleben_aufraeumen(login): assert (DATA_DIR / "uploads" / f).exists() -def test_wohnort_setzen_und_entfernen(login): +def test_wohnorte_mit_datum(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 + prag = {k: PRAG[k] for k in ("geonameId", "name", "country", "lat", "lon")} + anna.post("/api/reisen/wohnorte", json=berlin) + data = anna.post("/api/reisen/wohnorte", json={**prag, "since": "2024-01-01"}).json() + assert [(h["name"], h["since"]) for h in data["homes"]] == [("Berlin", None), ("Prag", "2024-01-01")] + # gleiches Datum ersetzt + data = anna.post("/api/reisen/wohnorte", json={**berlin, "since": "2024-01-01"}).json() + assert [(h["name"], h["since"]) for h in data["homes"]] == [("Berlin", None), ("Berlin", "2024-01-01")] + assert ben.get("/api/reisen").json()["homes"] == [] + assert anna.post("/api/reisen/wohnorte", json={**berlin, "since": "2024-02-30"}).status_code == 400 + assert ben.delete(f"/api/reisen/wohnorte/{data['homes'][0]['id']}").status_code == 404 + assert len(anna.delete(f"/api/reisen/wohnorte/{data['homes'][0]['id']}").json()["homes"]) == 1 + + +def test_hier_gewohnt(login): + anna = login("anna") + berlin = {k: BERLIN[k] for k in ("geonameId", "name", "country", "lat", "lon")} + anna.post("/api/reisen/wohnorte", json=berlin) + prag = anna.post("/api/reisen/staedte", json=PRAG).json()["cities"][0] + visit = anna.post(f"/api/reisen/staedte/{prag['id']}/besuche", json={"dateFrom": "2023-07-27", "dateTo": "2024-06-16"}).json()["visits"][0] + data = anna.post(f"/api/reisen/besuche/{visit['id']}/gewohnt").json() + assert [(h["name"], h["since"]) for h in data["homes"]] == [("Berlin", None), ("Prag", "2023-07-27"), ("Berlin", "2024-06-17")] + # Ohne "Bis" kein Aufenthalt + kurz = anna.post(f"/api/reisen/staedte/{prag['id']}/besuche", json={"dateFrom": "2025-01-01"}).json()["visits"][0] + assert anna.post(f"/api/reisen/besuche/{kurz['id']}/gewohnt").status_code == 400 + + +def test_anreise_je_besuch(login): + anna = login("anna") + prag = anna.post("/api/reisen/staedte", json=PRAG).json()["cities"][0] + url = f"/api/reisen/staedte/{prag['id']}/besuche" + data = anna.post(url, json={"dateFrom": "2024-05-01", "continued": True}).json() + assert data["visits"][0]["continued"] is True + v = data["visits"][0] + data = anna.put(f"/api/reisen/besuche/{v['id']}", json={**v, "continued": None}).json() + assert data["visits"][0]["continued"] is None + assert anna.post(url, json={"dateFrom": "2024-05-01", "continued": "ja"}).status_code == 400 + + +def test_besuch_bearbeitbar_wenn_fotodatei_fehlt(login): + anna = login("anna") + prag = anna.post("/api/reisen/staedte", json=PRAG).json()["cities"][0] + f = foto(anna) + v = anna.post(f"/api/reisen/staedte/{prag['id']}/besuche", json={"dateFrom": "2023-05-01", "photos": [f]}).json()["visits"][0] + (DATA_DIR / "uploads" / f).unlink() + res = anna.put(f"/api/reisen/besuche/{v['id']}", json={**v, "notes": "geändert"}) + assert res.status_code == 200 and res.json()["visits"][0]["notes"] == "geändert" + # Neue, nicht vorhandene Fotos werden weiter abgelehnt + assert anna.put(f"/api/reisen/besuche/{v['id']}", json={**v, "photos": [f, "9e" * 12 + ".jpg"]}).status_code == 400 diff --git a/frontend/src/components/reisen/TravelStats.jsx b/frontend/src/components/reisen/TravelStats.jsx index ce4dc3f..69b7489 100644 --- a/frontend/src/components/reisen/TravelStats.jsx +++ b/frontend/src/components/reisen/TravelStats.jsx @@ -1,6 +1,7 @@ +import { useState } from 'react'; import LAENDER from '../../data/laender.json'; import CitySearch from './CitySearch.jsx'; -import { BY_CODE, CONTINENTS, continentOf, countryName, num } from './laender.js'; +import { BY_CODE, CONTINENTS, continentOf, countryName, fmtDay, num } from './laender.js'; import { travelDays, travelRoute } from './route.js'; const pct = (part, total) => (total ? (part / total) * 100 : 0); @@ -11,7 +12,8 @@ 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, home, onSetHome, onRemoveHome, busy, onSelectCity }) { +export default function TravelStats({ visited, cities, visits, wishes, homes, onAddHome, onRemoveHome, busy, onSelectCity }) { + const [since, setSince] = useState(''); 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) => { @@ -30,10 +32,10 @@ export default function TravelStats({ visited, cities, visits, wishes, home, onS ]; // Kilometer: Reisen ab Wohnort, Rundreisen und Ausflüge (siehe route.js) - const route = travelRoute(visits, cities, home); - const days = travelDays(visits); + const route = travelRoute(visits, cities, homes); + const days = travelDays(visits, cities, homes); const summary = [ - `${route.trips} ${route.trips === 1 ? 'Reise' : 'Reisen'}${home ? ` ab ${home.name}` : ''}`, + `${route.trips} ${route.trips === 1 ? 'Reise' : 'Reisen'}${homes.length ? ' ab Wohnort' : ''}`, route.excursions && `${route.excursions} ${route.excursions === 1 ? 'Ausflug' : 'Ausflüge'}`, `${days} Reisetage`, ].filter(Boolean).join(' · '); @@ -51,16 +53,30 @@ export default function TravelStats({ visited, cities, visits, wishes, home, onS
-

Wohnort

- {home ? ( -

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

+

Wohnorte

+ {homes.length ? ( + ) : (

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

)} - +
+ + { if (await onAddHome({ ...r, since: since || null })) setSince(''); }} busy={busy} /> +
+

+ „Seit“ leer = von Anfang an. Zog man um oder wohnte länger im Ausland, einen weiteren Wohnort mit Datum eintragen – + oder beim Besuch „In dieser Zeit hier gewohnt“ wählen. +

{route.legs.length > 0 && (
Wie gerechnet? ({route.legs.length} Teilstrecken) @@ -73,7 +89,7 @@ export default function TravelStats({ visited, cities, visits, wishes, home, onS ))} - {!home &&

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

} + {!homes.length &&

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

}
)}
diff --git a/frontend/src/components/reisen/VisitDialog.jsx b/frontend/src/components/reisen/VisitDialog.jsx index 59b5250..aa93b89 100644 --- a/frontend/src/components/reisen/VisitDialog.jsx +++ b/frontend/src/components/reisen/VisitDialog.jsx @@ -6,7 +6,8 @@ import { imgUrl, uploadImage } from '../../api.js'; * Fotos werden beim Hochladen auf dem Server von Metadaten (GPS usw.) befreit. * onSave(body) / onDelete() liefern bei Erfolg den neuen Stand (truthy). */ -export default function VisitDialog({ ref, city, visit, onSave, onDelete, busy }) { +// pageError: Fehlermeldung der Seite (z. B. vom Speichern) – im Dialog anzeigen, sonst läge sie verdeckt dahinter +export default function VisitDialog({ ref, city, visit, onSave, onDelete, onLivedThere, busy, pageError }) { const today = new Date().toISOString().slice(0, 10); const [form, setForm] = useState(() => ({ dateFrom: visit?.dateFrom || today, @@ -14,6 +15,8 @@ export default function VisitDialog({ ref, city, visit, onSave, onDelete, busy } companions: visit?.companions || '', notes: visit?.notes || '', photos: visit?.photos || [], + // Anreise: '' automatisch, 'home' von zuhause, 'continued' vom vorherigen Ort weitergereist (Rundreise) + arrival: visit?.continued == null ? '' : visit.continued ? 'continued' : 'home', })); const [uploading, setUploading] = useState(0); const [error, setError] = useState(null); @@ -37,7 +40,9 @@ export default function VisitDialog({ ref, city, visit, onSave, onDelete, busy } async function submit(e) { e.preventDefault(); - if (await onSave({ ...form, dateTo: form.dateTo || null })) ref.current.close(); + const { arrival, ...rest } = form; + const continued = arrival === '' ? null : arrival === 'continued'; + if (await onSave({ ...rest, dateTo: form.dateTo || null, continued })) ref.current.close(); } async function remove() { @@ -60,6 +65,14 @@ export default function VisitDialog({ ref, city, visit, onSave, onDelete, busy }

„Bis“ leer lassen für einen einzelnen Tag.

+