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:
1 parent
56c4f38ef0
commit
1d7497bead
11 files changed
+325
-95
No files matched your search
@@ -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
@@ -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 (
|
||||||
|
|||||||
@@ -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
@@ -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"])
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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)} />
|
||||||
|
|||||||
@@ -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; }
|
||||||
Reference in new issue
Block a user