Reisen: Wohnorte mit Datum, Anreise je Besuch, Umzüge in der Kilometer-Rechnung

- Wohnorte als Liste mit "seit"-Datum (Tabelle travel_homes, ersetzt den
  einzelnen Wohnort; Migration Schritt 5 übernimmt ihn): Reisen starten
  beim damals gültigen Wohnort, Wechsel zählen als Umzug, Tage am
  Wohnort nicht als Reisetage
- "In dieser Zeit hier gewohnt" im Besuch: legt Wohnort ab Beginn und den
  vorherigen wieder ab Ende + 1 Tag an
- Anreise je Besuch (automatisch / von zuhause / vom vorherigen Ort):
  Rundreisen auch mit mehreren Tagen zwischen den Orten
- Fix: Besuche bleiben bearbeitbar, auch wenn eine Fotodatei fehlt;
  Speicherfehler erscheinen im Dialog statt verdeckt dahinter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 20:40:40 +02:00
1 parent 56c4f38ef0
commit 1d7497bead
11 files changed
+325 -95

No files matched your search

+10 -4
View File
@@ -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. 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). - **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, - **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** nördlichster/südlichster/östlichster/westlichster Ort und die Kilometer (Luftlinie):
(im Tab „Statistik“ festlegen, erscheint als ⌂ auf der Karte) zählt jede Reise mit Hin- und Heimweg; - **Wohnorte** (Tab „Statistik“, mit optionalem „seit“-Datum): Jede Reise startet und endet beim
Reisen mit höchstens einem Tag Abstand gelten als Rundreise, Besuche während eines längeren Aufenthalts Wohnort, der damals galt; ein Wechsel zählt als Umzug. Für einen längeren Auslandsaufenthalt im
als Ausflug hin und zurück. „Wie gerechnet?“ listet alle Teilstrecken. Reisetage zählen jeden Tag einmal. 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, 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 einzelne Personen lassen sich über ihre Schaltfläche aus- und einblenden (gemerkt pro Browser). Fremde
+9 -4
View File
@@ -229,17 +229,22 @@ CREATE TABLE IF NOT EXISTS travel_visits (
date_to TEXT, date_to TEXT,
companions TEXT NOT NULL DEFAULT '', companions TEXT NOT NULL DEFAULT '',
notes 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 created_at TEXT NOT NULL
); );
-- Wohnort pro Konto: Start und Ziel jeder Reise für die Kilometer-Statistik -- Wohnorte pro Konto mit "seit"-Datum (NULL = von Anfang an): Start und Ziel jeder Reise in der
CREATE TABLE IF NOT EXISTS travel_home ( -- Kilometer-Statistik; ein Wechsel (auch ein längerer Auslandsaufenthalt) zählt als Umzug
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, 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, geoname_id INTEGER NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
country TEXT NOT NULL, country TEXT NOT NULL,
lat REAL 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 ( CREATE TABLE IF NOT EXISTS travel_visit_photos (
+15
View File
@@ -256,10 +256,25 @@ def _v4_wunschliste(conn) -> None:
_add_column(conn, "travel_cities", "wish", "INTEGER NOT NULL DEFAULT 0") _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. # Reihenfolge = Versionsnummer (Index + 1). Nur anhängen, nie umsortieren oder entfernen.
STEPS = [ STEPS = [
_v1_altbestand, _v1_altbestand,
_v2_datenschutz, _v2_datenschutz,
_v3_kategorien, _v3_kategorien,
_v4_wunschliste, _v4_wunschliste,
_v5_wohnorte,
] ]
+82 -28
View File
@@ -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"], "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"], []), "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 for v in rows
] ]
@@ -90,11 +91,17 @@ def _visits(conn, user_id: int) -> list:
def _state(conn, user_id: int) -> dict: def _state(conn, user_id: int) -> dict:
cities = conn.execute("SELECT * FROM travel_cities WHERE user_id = ? ORDER BY name", (user_id,)).fetchall() 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() 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 { return {
"cities": [_city(r) for r in cities], "cities": [_city(r) for r in cities],
"countries": [r["code"] for r in countries], "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), "visits": _visits(conn, user_id),
"others": _others(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"]) return _state(conn, user["id"])
# ---------- Wohnort ---------- # ---------- Wohnorte (mit "seit"-Datum) ----------
MAX_HOMES = 50
@router.put("/api/reisen/wohnort") def _add_home(conn, user_id: int, city: dict, since) -> None:
def set_home(data: dict = Body(...), user: dict = Depends(current_user)): """Wohnort ab Datum eintragen; gleiches Datum wird ersetzt."""
"""Wohnort festlegen (Ergebnis der Städtesuche); nur für die eigene Kilometer-Statistik.""" 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") geoname_id = data.get("geonameId")
if isinstance(geoname_id, bool) or not isinstance(geoname_id, int) or geoname_id <= 0: if isinstance(geoname_id, bool) or not isinstance(geoname_id, int) or geoname_id <= 0:
raise ValidationError("Ungültige Stadt.") raise ValidationError("Ungültige Stadt.")
country = data.get("country") country = data.get("country")
if not isinstance(country, str) or not re.fullmatch(r"[A-Z]{2}", country): if not isinstance(country, str) or not re.fullmatch(r"[A-Z]{2}", country):
raise ValidationError("Ungültiges Land.") raise ValidationError("Ungültiges Land.")
values = ( city = {
user["id"], geoname_id, clean_text(data.get("name"), "Name", 100), country, "geoname_id": geoname_id, "name": clean_text(data.get("name"), "Name", 100), "country": country,
_number(data.get("lat"), "Breitengrad", -90, 90), _number(data.get("lon"), "Längengrad", -180, 180), "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: with transaction() as conn:
conn.execute( _add_home(conn, user["id"], city, since)
"""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"]) return _state(conn, user["id"])
@router.delete("/api/reisen/wohnort") @router.delete("/api/reisen/wohnorte/{home_id}")
def delete_home(user: dict = Depends(current_user)): def delete_home(home_id: int, user: dict = Depends(current_user)):
with transaction() as conn: 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"]) return _state(conn, user["id"])
@@ -239,7 +287,8 @@ def _date(value, field: str, required: bool):
return value 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_from = _date(data.get("dateFrom"), "Von", True)
date_to = _date(data.get("dateTo"), "Bis", False) date_to = _date(data.get("dateTo"), "Bis", False)
if date_to and date_to < date_from: 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): 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}).") raise ValidationError(f"Ungültige Fotos (max. {MAX_PHOTOS}).")
photos = list(dict.fromkeys(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.") 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: 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) @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)): 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.""" """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: with transaction() as conn:
_own_city(conn, city_id, user) _own_city(conn, city_id, user)
if conn.execute( if conn.execute(
@@ -287,8 +339,9 @@ def add_visit(city_id: int, data: dict = Body(...), user: dict = Depends(current
).fetchone()[0] >= MAX_VISITS: ).fetchone()[0] >= MAX_VISITS:
raise ValidationError(f"Maximal {MAX_VISITS} Besuche.") raise ValidationError(f"Maximal {MAX_VISITS} Besuche.")
visit_id = conn.execute( visit_id = conn.execute(
"INSERT INTO travel_visits (city_id, date_from, date_to, companions, notes, created_at) VALUES (?, ?, ?, ?, ?, ?)", """INSERT INTO travel_visits (city_id, date_from, date_to, companions, notes, continued, created_at)
(city_id, date_from, date_to, companions, notes, now_iso()), VALUES (?, ?, ?, ?, ?, ?, ?)""",
(city_id, date_from, date_to, companions, notes, None if continued is None else int(continued), now_iso()),
).lastrowid ).lastrowid
_save_photos(conn, visit_id, photos) _save_photos(conn, visit_id, photos)
conn.execute("UPDATE travel_cities SET wish = 0 WHERE id = ?", (city_id,)) 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}") @router.put("/api/reisen/besuche/{visit_id}")
def update_visit(visit_id: int, data: dict = Body(...), user: dict = Depends(current_user)): 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: with transaction() as conn:
_own_visit(conn, visit_id, user) _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( conn.execute(
"UPDATE travel_visits SET date_from = ?, date_to = ?, companions = ?, notes = ? WHERE id = ?", "UPDATE travel_visits SET date_from = ?, date_to = ?, companions = ?, notes = ?, continued = ? WHERE id = ?",
(date_from, date_to, companions, notes, visit_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)) remove_unused(conn, _save_photos(conn, visit_id, photos))
return _state(conn, user["id"]) return _state(conn, user["id"])
+13
View File
@@ -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)") 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] sql = conn.execute("SELECT sql FROM sqlite_master WHERE name = 'kanban_checklist'").fetchone()[0]
assert "kanban_tasks_alt" not in sql 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()
+49 -6
View File
@@ -114,11 +114,54 @@ def test_reisefotos_ueberleben_aufraeumen(login):
assert (DATA_DIR / "uploads" / f).exists() 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") anna, ben = login("anna"), login("ben")
berlin = {k: BERLIN[k] for k in ("geonameId", "name", "country", "lat", "lon")} berlin = {k: BERLIN[k] for k in ("geonameId", "name", "country", "lat", "lon")}
data = anna.put("/api/reisen/wohnort", json=berlin).json() prag = {k: PRAG[k] for k in ("geonameId", "name", "country", "lat", "lon")}
assert data["home"] == berlin anna.post("/api/reisen/wohnorte", json=berlin)
assert ben.get("/api/reisen").json()["home"] is None # nur das eigene Konto data = anna.post("/api/reisen/wohnorte", json={**prag, "since": "2024-01-01"}).json()
assert anna.put("/api/reisen/wohnort", json={**berlin, "lat": 99}).status_code == 400 assert [(h["name"], h["since"]) for h in data["homes"]] == [("Berlin", None), ("Prag", "2024-01-01")]
assert anna.delete("/api/reisen/wohnort").json()["home"] is None # 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
+29 -13
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import LAENDER from '../../data/laender.json'; import LAENDER from '../../data/laender.json';
import CitySearch from './CitySearch.jsx'; 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'; import { travelDays, travelRoute } from './route.js';
const pct = (part, total) => (total ? (part / total) * 100 : 0); 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. * 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). * 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 codes = [...visited].filter((c) => BY_CODE.has(c));
const area = codes.reduce((s, c) => s + (BY_CODE.get(c).area || 0), 0); const area = codes.reduce((s, c) => s + (BY_CODE.get(c).area || 0), 0);
const continents = CONTINENTS.map((name) => { 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) // Kilometer: Reisen ab Wohnort, Rundreisen und Ausflüge (siehe route.js)
const route = travelRoute(visits, cities, home); const route = travelRoute(visits, cities, homes);
const days = travelDays(visits); const days = travelDays(visits, cities, homes);
const summary = [ 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'}`, route.excursions && `${route.excursions} ${route.excursions === 1 ? 'Ausflug' : 'Ausflüge'}`,
`${days} Reisetage`, `${days} Reisetage`,
].filter(Boolean).join(' · '); ].filter(Boolean).join(' · ');
@@ -51,16 +53,30 @@ export default function TravelStats({ visited, cities, visits, wishes, home, onS
</div> </div>
<section className="stat-home" aria-labelledby="stat-wohnort"> <section className="stat-home" aria-labelledby="stat-wohnort">
<h2 className="section-title" id="stat-wohnort">Wohnort</h2> <h2 className="section-title" id="stat-wohnort">Wohnorte</h2>
{home ? ( {homes.length ? (
<p className="stat-home-current"> <ul className="stat-homes">
⌂ <strong>{home.name}</strong> <small>{countryName(home.country)}</small> {homes.map((h) => (
<button className="link-btn danger" type="button" disabled={busy} onClick={onRemoveHome}>Entfernen</button> <li key={h.id}>
</p> ⌂ <strong>{h.name}</strong> <small>{countryName(h.country)} · {h.since ? `seit ${fmtDay(h.since)}` : 'von Anfang an'}</small>
<button className="link-btn danger" type="button" disabled={busy} onClick={() => onRemoveHome(h)} aria-label={`Wohnort ${h.name} entfernen`}>✕</button>
</li>
))}
</ul>
) : ( ) : (
<p className="hint">Mit Wohnort zählt jede Reise als Hin- und Rückweg ab zuhause.</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} /> <div className="stat-home-add">
<label className="stat-home-since">
<span>Seit</span>
<input type="date" value={since} onChange={(e) => setSince(e.target.value)} aria-describedby="since-hint" />
</label>
<CitySearch mode="home" cities={[]} onAdd={async (r) => { if (await onAddHome({ ...r, since: since || null })) setSince(''); }} busy={busy} />
</div>
<p className="hint" id="since-hint">
„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.
</p>
{route.legs.length > 0 && ( {route.legs.length > 0 && (
<details className="stat-route"> <details className="stat-route">
<summary>Wie gerechnet? ({route.legs.length} Teilstrecken)</summary> <summary>Wie gerechnet? ({route.legs.length} Teilstrecken)</summary>
@@ -73,7 +89,7 @@ export default function TravelStats({ visited, cities, visits, wishes, home, onS
</li> </li>
))} ))}
</ol> </ol>
{!home && <p className="hint">Ohne Wohnort fehlen Hin- und Heimwege; die Reisen werden direkt nacheinander verbunden.</p>} {!homes.length && <p className="hint">Ohne Wohnort fehlen Hin- und Heimwege; die Reisen werden direkt nacheinander verbunden.</p>}
</details> </details>
)} )}
</section> </section>
+25 -3
View File
@@ -6,7 +6,8 @@ import { imgUrl, uploadImage } from '../../api.js';
* Fotos werden beim Hochladen auf dem Server von Metadaten (GPS usw.) befreit. * Fotos werden beim Hochladen auf dem Server von Metadaten (GPS usw.) befreit.
* onSave(body) / onDelete() liefern bei Erfolg den neuen Stand (truthy). * 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 today = new Date().toISOString().slice(0, 10);
const [form, setForm] = useState(() => ({ const [form, setForm] = useState(() => ({
dateFrom: visit?.dateFrom || today, dateFrom: visit?.dateFrom || today,
@@ -14,6 +15,8 @@ export default function VisitDialog({ ref, city, visit, onSave, onDelete, busy }
companions: visit?.companions || '', companions: visit?.companions || '',
notes: visit?.notes || '', notes: visit?.notes || '',
photos: visit?.photos || [], 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 [uploading, setUploading] = useState(0);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -37,7 +40,9 @@ export default function VisitDialog({ ref, city, visit, onSave, onDelete, busy }
async function submit(e) { async function submit(e) {
e.preventDefault(); 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() { async function remove() {
@@ -60,6 +65,14 @@ export default function VisitDialog({ ref, city, visit, onSave, onDelete, busy }
</label> </label>
</div> </div>
<p className="hint visit-hint" id="bis-hint">„Bis“ leer lassen für einen einzelnen Tag.</p> <p className="hint visit-hint" id="bis-hint">„Bis“ leer lassen für einen einzelnen Tag.</p>
<label>
Anreise
<select {...field('arrival')}>
<option value="">Automatisch (höchstens 1 Tag nach dem vorigen Besuch = Weiterreise)</option>
<option value="home">Von zuhause</option>
<option value="continued">Vom vorherigen Ort weitergereist (Rundreise)</option>
</select>
</label>
<label> <label>
Begleitung <small>(optional)</small> Begleitung <small>(optional)</small>
<input {...field('companions')} maxLength={200} placeholder="z. B. Anna, Ben" /> <input {...field('companions')} maxLength={200} placeholder="z. B. Anna, Ben" />
@@ -87,7 +100,16 @@ export default function VisitDialog({ ref, city, visit, onSave, onDelete, busy }
</button> </button>
<p className="hint">Standort, Kamera und Aufnahmezeit werden beim Hochladen aus den Fotos entfernt.</p> <p className="hint">Standort, Kamera und Aufnahmezeit werden beim Hochladen aus den Fotos entfernt.</p>
</fieldset> </fieldset>
{error && <p className="error">{error}</p>} {visit?.dateTo && visit.dateTo !== visit.dateFrom && (
<p className="hint visit-lived">
Längerer Aufenthalt, z. B. Auslandssemester?{' '}
<button className="link-btn" type="button" disabled={busy} onClick={async () => { if (await onLivedThere(visit)) ref.current.close(); }}>
⌂ In dieser Zeit hier gewohnt
</button>
{' '}– macht {city.name} für diesen Zeitraum zum Wohnort (Strecken als Umzug, keine Reisetage).
</p>
)}
{(error || pageError) && <p className="error" role="alert">{error || pageError}</p>}
<div className="form-actions"> <div className="form-actions">
{visit && <button className="link-btn danger task-delete" type="button" disabled={busy} onClick={remove}>Löschen</button>} {visit && <button className="link-btn danger task-delete" type="button" disabled={busy} onClick={remove}>Löschen</button>}
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button> <button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
+78 -33
View File
@@ -1,63 +1,108 @@
// Reiseroute für die Kilometer-Statistik, aus den eingetragenen Besuchen (Luftlinie): // Reiseroute für die Kilometer-Statistik, aus Besuchen und Wohnorten (Luftlinie):
// - Reise: Wohnort → Ziel → Wohnort. Beginnt die nächste Reise höchstens einen Tag nach dem Ende der vorigen, // - Reise: Wohnort → Ziel(e) → Wohnort. Welcher Wohnort gilt, entscheidet das "seit"-Datum der Wohnorte.
// ist es eine Rundreise (Wohnort → A → B → Wohnort). // - Rundreise: Besuche mit Anreise "vom vorherigen Ort" (oder automatisch: höchstens 1 Tag Abstand)
// - Ausflug: Besuch, der während eines längeren Aufenthalts beginnt (z. B. David während San José) – // gehören zur selben Reise: Wohnort → A → B → Wohnort.
// zählt als Hin- und Rückweg zum Aufenthaltsort. // - Ausflug: Besuch, der während eines längeren Aufenthalts beginnt – Hin- und Rückweg zum Aufenthaltsort.
// - Umzug: Wechsel des Wohnorts (auch ein längerer Auslandsaufenthalt) zählt als eigene Strecke.
// Ohne Wohnort fehlen Hin- und Heimwege; die Reisen werden dann direkt nacheinander verbunden. // Ohne Wohnort fehlen Hin- und Heimwege; die Reisen werden dann direkt nacheinander verbunden.
import { distanceKm } from './laender.js'; import { distanceKm } from './laender.js';
const day = (d) => Math.round(Date.parse(`${d}T12:00:00Z`) / 864e5); const day = (d) => Math.round(Date.parse(`${d}T12:00:00Z`) / 864e5);
const nextDay = (d) => new Date(Date.parse(`${d}T12:00:00Z`) + 864e5).toISOString().slice(0, 10);
const samePlace = (a, b) => Boolean(a && b && a.lat === b.lat && a.lon === b.lon);
export function travelRoute(visits, cities, home) { /** Wohnort an einem Tag; vor dem ersten Datum gilt der früheste. homes: sortiert, "von Anfang an" zuerst. */
export function homeAt(homes, date) {
let found = null;
for (const h of homes) if (!h.since || h.since <= date) found = h;
return found || homes[0] || null;
}
// Besuche zu Reisen zusammenfassen (mit Ausflügen je Besuch)
function groupTrips(visits, cities) {
const byId = new Map(cities.map((c) => [c.id, c])); const byId = new Map(cities.map((c) => [c.id, c]));
const items = visits const items = visits
.map((v) => ({ ...v, city: byId.get(v.cityId), to: v.dateTo || v.dateFrom })) .map((v) => ({ ...v, city: byId.get(v.cityId), to: v.dateTo || v.dateFrom, excursions: [] }))
.filter((v) => v.city) .filter((v) => v.city)
.sort((a, b) => a.dateFrom.localeCompare(b.dateFrom) || b.to.localeCompare(a.to)); .sort((a, b) => a.dateFrom.localeCompare(b.dateFrom) || b.to.localeCompare(a.to));
// Ausflüge ihrem Aufenthalt zuordnen
const trips = []; const trips = [];
for (const v of items) { for (const v of items) {
const base = trips.at(-1)?.visit; const trip = trips.at(-1);
if (base && v.dateFrom >= base.dateFrom && v.dateFrom <= base.to) trips.at(-1).excursions.push(v); const stay = trip?.visits.findLast((s) => v.dateFrom >= s.dateFrom && v.dateFrom <= s.to);
else trips.push({ visit: v, excursions: [] }); if (stay) {
stay.excursions.push(v);
continue;
}
const continued = v.continued ?? (trip && day(v.dateFrom) - day(trip.end) <= 1);
if (trip && continued) {
trip.visits.push(v);
if (v.to > trip.end) trip.end = v.to;
} else {
trips.push({ visits: [v], start: v.dateFrom, end: v.to });
}
}
return trips;
} }
export function travelRoute(visits, cities, homes = []) {
const trips = groupTrips(visits, cities);
const dated = homes.filter((h) => h.since);
const legs = []; const legs = [];
const leg = (from, to, kind) => { const leg = (from, to, kind) => {
if (!from || !to || (from.lat === to.lat && from.lon === to.lon)) return; if (!from || !to || samePlace(from, to)) return;
legs.push({ from: from.name, to: to.name, km: distanceKm(from, to), kind }); legs.push({ from: from.name, to: to.name, km: distanceKm(from, to), kind });
}; };
let pos = null;
trips.forEach((t, i) => { let home = homes[0] || null; // aktueller Wohnort
const chained = i > 0 && day(t.visit.dateFrom) - day(trips[i - 1].visit.to) <= 1; let pos = home; // wo man gerade ist
if (home && !chained) { let next = dated.indexOf(home) + 1; // nächster noch nicht verarbeiteter Wohnort-Wechsel
leg(pos, home, 'Heimweg'); // Wohnort-Wechsel bis zu einem Tag als Umzug verbuchen (nur zwischen den Reisen)
pos = home; const moveUntil = (date) => {
while (next < dated.length && dated[next].since <= date) {
leg(pos, dated[next], 'Umzug');
pos = home = dated[next];
next += 1;
} }
// 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; let realTrips = 0;
for (const ex of t.excursions) { let excursions = 0;
for (const trip of trips) {
moveUntil(trip.start);
if (!trip.visits.every((v) => samePlace(v.city, home))) realTrips += 1;
trip.visits.forEach((v, i) => {
leg(pos, v.city, i === 0 && home ? 'Hinweg' : 'Weiterreise');
pos = v.city;
for (const ex of v.excursions) {
excursions += 1;
leg(pos, ex.city, 'Ausflug'); leg(pos, ex.city, 'Ausflug');
leg(ex.city, pos, 'Ausflug zurück'); leg(ex.city, pos, 'Ausflug zurück');
} }
}); });
if (home && trips.length) leg(pos, home, 'Heimweg'); if (home) {
// Zurück zum Wohnort, der nach der Reise gilt – hat er sich während der Reise geändert, ist es ein Umzug
const after = homeAt(homes, nextDay(trip.end));
leg(pos, after, after === home ? 'Heimweg' : 'Umzug');
while (next < dated.length && dated[next].since <= nextDay(trip.end)) next += 1;
pos = home = after;
}
}
moveUntil('9999-12-31');
return { return { legs, km: legs.reduce((s, l) => s + l.km, 0), trips: realTrips, excursions };
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) // Reisetage: jeder Kalendertag einmal; Tage am damaligen Wohnort (z. B. Auslandsaufenthalt) zählen nicht
export function travelDays(visits) { export function travelDays(visits, cities, homes = []) {
const byId = new Map(cities.map((c) => [c.id, c]));
const days = new Set(); const days = new Set();
for (const v of visits) { for (const v of visits) {
for (let d = day(v.dateFrom); d <= day(v.dateTo || v.dateFrom); d += 1) days.add(d); const city = byId.get(v.cityId);
if (!city) continue;
const end = v.dateTo || v.dateFrom;
for (let d = v.dateFrom; d <= end; d = nextDay(d)) {
if (!samePlace(city, homeAt(homes, d))) days.add(d);
}
} }
return days.size; return days.size;
} }
+7 -4
View File
@@ -7,6 +7,7 @@ import InfoCard from '../components/reisen/InfoCard.jsx';
import { BY_NUM, countryName } from '../components/reisen/laender.js'; import { BY_NUM, countryName } from '../components/reisen/laender.js';
import Timeline from '../components/reisen/Timeline.jsx'; import Timeline from '../components/reisen/Timeline.jsx';
import TravelStats from '../components/reisen/TravelStats.jsx'; import TravelStats from '../components/reisen/TravelStats.jsx';
import { homeAt } from '../components/reisen/route.js';
import VisitDialog from '../components/reisen/VisitDialog.jsx'; import VisitDialog from '../components/reisen/VisitDialog.jsx';
import VisitedList from '../components/reisen/VisitedList.jsx'; import VisitedList from '../components/reisen/VisitedList.jsx';
import WorldMap from '../components/reisen/WorldMap.jsx'; import WorldMap from '../components/reisen/WorldMap.jsx';
@@ -205,7 +206,7 @@ export default function Reisen() {
visited={visited} visited={visited}
cities={own} cities={own}
otherCities={otherCities} otherCities={otherCities}
home={data?.home} home={homeAt(data?.homes || [], new Date().toISOString().slice(0, 10))}
selected={selected} selected={selected}
onSelect={(sel) => select(sel)} onSelect={(sel) => select(sel)}
/> />
@@ -259,10 +260,10 @@ export default function Reisen() {
cities={cities} cities={cities}
visits={visits} visits={visits}
wishes={wishes.length} wishes={wishes.length}
home={data.home} homes={data.homes || []}
busy={busy} busy={busy}
onSetHome={(result) => run('PUT', '/api/reisen/wohnort', result)} onAddHome={(home) => run('POST', '/api/reisen/wohnorte', home)}
onRemoveHome={() => run('DELETE', '/api/reisen/wohnort')} onRemoveHome={(home) => run('DELETE', `/api/reisen/wohnorte/${home.id}`)}
onSelectCity={showCity} onSelectCity={showCity}
/> />
)} )}
@@ -283,10 +284,12 @@ export default function Reisen() {
city={visitDialog.city} city={visitDialog.city}
visit={visitDialog.visit} visit={visitDialog.visit}
busy={busy} busy={busy}
pageError={error}
onSave={(body) => (visitDialog.visit onSave={(body) => (visitDialog.visit
? run('PUT', `/api/reisen/besuche/${visitDialog.visit.id}`, body) ? run('PUT', `/api/reisen/besuche/${visitDialog.visit.id}`, body)
: run('POST', `/api/reisen/staedte/${visitDialog.city.id}/besuche`, body))} : run('POST', `/api/reisen/staedte/${visitDialog.city.id}/besuche`, body))}
onDelete={() => run('DELETE', `/api/reisen/besuche/${visitDialog.visit.id}`)} onDelete={() => run('DELETE', `/api/reisen/besuche/${visitDialog.visit.id}`)}
onLivedThere={(visit) => run('POST', `/api/reisen/besuche/${visit.id}/gewohnt`)}
/> />
)} )}
<Lightbox src={zoom} onClose={() => setZoom(null)} /> <Lightbox src={zoom} onClose={() => setZoom(null)} />
+8
View File
@@ -249,3 +249,11 @@
.stat-route li span { margin-right: 8px; } .stat-route li span { margin-right: 8px; }
.stat-route li small { color: var(--muted); margin-right: 8px; } .stat-route li small { color: var(--muted); margin-right: 8px; }
.stat-route li strong { font-variant-numeric: tabular-nums; } .stat-route li strong { font-variant-numeric: tabular-nums; }
.stat-homes { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
.stat-homes li { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.stat-homes small { color: var(--muted); }
.stat-home-add { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; }
.stat-home-add .city-search-row { flex: 1 1 320px; }
.stat-home-since { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 0.85rem; }
.stat-home-since input { font: inherit; color: var(--bright); background: var(--input-bg); border: 1px solid var(--border); border-radius: var(--radius-s); min-height: 50px; padding: 0 10px; }
.visit-lived { margin: 0; }