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
+343
-113
No files matched your search
@@ -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
|
||||
</div>
|
||||
|
||||
<section className="stat-home" aria-labelledby="stat-wohnort">
|
||||
<h2 className="section-title" id="stat-wohnort">Wohnort</h2>
|
||||
{home ? (
|
||||
<p className="stat-home-current">
|
||||
⌂ <strong>{home.name}</strong> <small>{countryName(home.country)}</small>
|
||||
<button className="link-btn danger" type="button" disabled={busy} onClick={onRemoveHome}>Entfernen</button>
|
||||
</p>
|
||||
<h2 className="section-title" id="stat-wohnort">Wohnorte</h2>
|
||||
{homes.length ? (
|
||||
<ul className="stat-homes">
|
||||
{homes.map((h) => (
|
||||
<li key={h.id}>
|
||||
⌂ <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>
|
||||
)}
|
||||
<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 && (
|
||||
<details className="stat-route">
|
||||
<summary>Wie gerechnet? ({route.legs.length} Teilstrecken)</summary>
|
||||
@@ -73,7 +89,7 @@ export default function TravelStats({ visited, cities, visits, wishes, home, onS
|
||||
</li>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -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 }
|
||||
</label>
|
||||
</div>
|
||||
<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>
|
||||
Begleitung <small>(optional)</small>
|
||||
<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>
|
||||
<p className="hint">Standort, Kamera und Aufnahmezeit werden beim Hochladen aus den Fotos entfernt.</p>
|
||||
</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">
|
||||
{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>
|
||||
|
||||
@@ -1,63 +1,108 @@
|
||||
// Reiseroute für die Kilometer-Statistik, aus den eingetragenen Besuchen (Luftlinie):
|
||||
// - Reise: Wohnort → Ziel → Wohnort. Beginnt die nächste Reise höchstens einen Tag nach dem Ende der vorigen,
|
||||
// ist es eine Rundreise (Wohnort → A → B → Wohnort).
|
||||
// - Ausflug: Besuch, der während eines längeren Aufenthalts beginnt (z. B. David während San José) –
|
||||
// zählt als Hin- und Rückweg zum Aufenthaltsort.
|
||||
// Reiseroute für die Kilometer-Statistik, aus Besuchen und Wohnorten (Luftlinie):
|
||||
// - Reise: Wohnort → Ziel(e) → Wohnort. Welcher Wohnort gilt, entscheidet das "seit"-Datum der Wohnorte.
|
||||
// - Rundreise: Besuche mit Anreise "vom vorherigen Ort" (oder automatisch: höchstens 1 Tag Abstand)
|
||||
// gehören zur selben Reise: Wohnort → A → B → Wohnort.
|
||||
// - 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.
|
||||
import { distanceKm } from './laender.js';
|
||||
|
||||
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) {
|
||||
const byId = new Map(cities.map((c) => [c.id, c]));
|
||||
const items = visits
|
||||
.map((v) => ({ ...v, city: byId.get(v.cityId), to: v.dateTo || v.dateFrom }))
|
||||
.filter((v) => v.city)
|
||||
.sort((a, b) => a.dateFrom.localeCompare(b.dateFrom) || b.to.localeCompare(a.to));
|
||||
|
||||
// Ausflüge ihrem Aufenthalt zuordnen
|
||||
const trips = [];
|
||||
for (const v of items) {
|
||||
const base = trips.at(-1)?.visit;
|
||||
if (base && v.dateFrom >= base.dateFrom && v.dateFrom <= base.to) trips.at(-1).excursions.push(v);
|
||||
else trips.push({ visit: v, excursions: [] });
|
||||
}
|
||||
|
||||
const legs = [];
|
||||
const leg = (from, to, kind) => {
|
||||
if (!from || !to || (from.lat === to.lat && from.lon === to.lon)) return;
|
||||
legs.push({ from: from.name, to: to.name, km: distanceKm(from, to), kind });
|
||||
};
|
||||
let pos = null;
|
||||
trips.forEach((t, i) => {
|
||||
const chained = i > 0 && day(t.visit.dateFrom) - day(trips[i - 1].visit.to) <= 1;
|
||||
if (home && !chained) {
|
||||
leg(pos, home, 'Heimweg');
|
||||
pos = home;
|
||||
}
|
||||
// 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;
|
||||
for (const ex of t.excursions) {
|
||||
leg(pos, ex.city, 'Ausflug');
|
||||
leg(ex.city, pos, 'Ausflug zurück');
|
||||
}
|
||||
});
|
||||
if (home && trips.length) leg(pos, home, 'Heimweg');
|
||||
|
||||
return {
|
||||
legs,
|
||||
km: legs.reduce((s, l) => s + l.km, 0),
|
||||
trips: trips.length,
|
||||
excursions: trips.reduce((s, t) => s + t.excursions.length, 0),
|
||||
};
|
||||
/** 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;
|
||||
}
|
||||
|
||||
// Reisetage: jeder Kalendertag nur einmal (Ausflüge während eines Aufenthalts zählen nicht doppelt)
|
||||
export function travelDays(visits) {
|
||||
// Besuche zu Reisen zusammenfassen (mit Ausflügen je Besuch)
|
||||
function groupTrips(visits, cities) {
|
||||
const byId = new Map(cities.map((c) => [c.id, c]));
|
||||
const items = visits
|
||||
.map((v) => ({ ...v, city: byId.get(v.cityId), to: v.dateTo || v.dateFrom, excursions: [] }))
|
||||
.filter((v) => v.city)
|
||||
.sort((a, b) => a.dateFrom.localeCompare(b.dateFrom) || b.to.localeCompare(a.to));
|
||||
const trips = [];
|
||||
for (const v of items) {
|
||||
const trip = trips.at(-1);
|
||||
const stay = trip?.visits.findLast((s) => v.dateFrom >= s.dateFrom && v.dateFrom <= s.to);
|
||||
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 leg = (from, to, kind) => {
|
||||
if (!from || !to || samePlace(from, to)) return;
|
||||
legs.push({ from: from.name, to: to.name, km: distanceKm(from, to), kind });
|
||||
};
|
||||
|
||||
let home = homes[0] || null; // aktueller Wohnort
|
||||
let pos = home; // wo man gerade ist
|
||||
let next = dated.indexOf(home) + 1; // nächster noch nicht verarbeiteter Wohnort-Wechsel
|
||||
// Wohnort-Wechsel bis zu einem Tag als Umzug verbuchen (nur zwischen den Reisen)
|
||||
const moveUntil = (date) => {
|
||||
while (next < dated.length && dated[next].since <= date) {
|
||||
leg(pos, dated[next], 'Umzug');
|
||||
pos = home = dated[next];
|
||||
next += 1;
|
||||
}
|
||||
};
|
||||
|
||||
let realTrips = 0;
|
||||
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(ex.city, pos, 'Ausflug zurück');
|
||||
}
|
||||
});
|
||||
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 { legs, km: legs.reduce((s, l) => s + l.km, 0), trips: realTrips, excursions };
|
||||
}
|
||||
|
||||
// Reisetage: jeder Kalendertag einmal; Tage am damaligen Wohnort (z. B. Auslandsaufenthalt) zählen nicht
|
||||
export function travelDays(visits, cities, homes = []) {
|
||||
const byId = new Map(cities.map((c) => [c.id, c]));
|
||||
const days = new Set();
|
||||
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;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import InfoCard from '../components/reisen/InfoCard.jsx';
|
||||
import { BY_NUM, countryName } from '../components/reisen/laender.js';
|
||||
import Timeline from '../components/reisen/Timeline.jsx';
|
||||
import TravelStats from '../components/reisen/TravelStats.jsx';
|
||||
import { homeAt } from '../components/reisen/route.js';
|
||||
import VisitDialog from '../components/reisen/VisitDialog.jsx';
|
||||
import VisitedList from '../components/reisen/VisitedList.jsx';
|
||||
import WorldMap from '../components/reisen/WorldMap.jsx';
|
||||
@@ -205,7 +206,7 @@ export default function Reisen() {
|
||||
visited={visited}
|
||||
cities={own}
|
||||
otherCities={otherCities}
|
||||
home={data?.home}
|
||||
home={homeAt(data?.homes || [], new Date().toISOString().slice(0, 10))}
|
||||
selected={selected}
|
||||
onSelect={(sel) => select(sel)}
|
||||
/>
|
||||
@@ -259,10 +260,10 @@ export default function Reisen() {
|
||||
cities={cities}
|
||||
visits={visits}
|
||||
wishes={wishes.length}
|
||||
home={data.home}
|
||||
homes={data.homes || []}
|
||||
busy={busy}
|
||||
onSetHome={(result) => run('PUT', '/api/reisen/wohnort', result)}
|
||||
onRemoveHome={() => run('DELETE', '/api/reisen/wohnort')}
|
||||
onAddHome={(home) => run('POST', '/api/reisen/wohnorte', home)}
|
||||
onRemoveHome={(home) => run('DELETE', `/api/reisen/wohnorte/${home.id}`)}
|
||||
onSelectCity={showCity}
|
||||
/>
|
||||
)}
|
||||
@@ -283,10 +284,12 @@ export default function Reisen() {
|
||||
city={visitDialog.city}
|
||||
visit={visitDialog.visit}
|
||||
busy={busy}
|
||||
pageError={error}
|
||||
onSave={(body) => (visitDialog.visit
|
||||
? run('PUT', `/api/reisen/besuche/${visitDialog.visit.id}`, body)
|
||||
: run('POST', `/api/reisen/staedte/${visitDialog.city.id}/besuche`, body))}
|
||||
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)} />
|
||||
|
||||
@@ -249,3 +249,11 @@
|
||||
.stat-route li span { margin-right: 8px; }
|
||||
.stat-route li small { color: var(--muted); margin-right: 8px; }
|
||||
.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