Files
p42.f-w/frontend/src/components/reisen/TravelStats.jsx
T
guyg1474andClaude Opus 5.5 1d7497bead 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>
2026-10-15 20:40:40 +02:00

128 lines
6.5 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import LAENDER from '../../data/laender.json';
import CitySearch from './CitySearch.jsx';
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);
const fmtPct = (p) => `${p.toLocaleString('de-DE', { maximumFractionDigits: p < 10 ? 1 : 0 })} %`;
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, 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) => {
const total = LAENDER.filter((c) => c.continent === name).length;
const seen = codes.filter((c) => continentOf(c) === name).length;
return { name, total, seen };
}).filter((c) => c.total);
// Äußerste Punkte (nur besuchte Städte)
const extreme = (pick) => (cities.length ? cities.reduce((a, b) => (pick(b) > pick(a) ? b : a)) : null);
const points = [
['Nördlichster Ort', extreme((c) => c.lat), (c) => `${c.lat.toFixed(1).replace('.', ',')}° N`],
['Südlichster Ort', extreme((c) => -c.lat), (c) => `${Math.abs(c.lat).toFixed(1).replace('.', ',')}° ${c.lat < 0 ? 'S' : 'N'}`],
['Östlichster Ort', extreme((c) => c.lon), (c) => `${Math.abs(c.lon).toFixed(1).replace('.', ',')}° ${c.lon < 0 ? 'W' : 'O'}`],
['Westlichster Ort', extreme((c) => -c.lon), (c) => `${Math.abs(c.lon).toFixed(1).replace('.', ',')}° ${c.lon < 0 ? 'W' : 'O'}`],
];
// Kilometer: Reisen ab Wohnort, Rundreisen und Ausflüge (siehe route.js)
const route = travelRoute(visits, cities, homes);
const days = travelDays(visits, cities, homes);
const summary = [
`${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(' · ');
return (
<div className="travel-stats">
<div className="stat-tiles">
<div className="stat-tile"><strong>{fmtPct(pct(codes.length, LAENDER.length))}</strong><span>der Welt ({codes.length} von {LAENDER.length} Ländern und Gebieten)</span></div>
<div className="stat-tile"><strong>{fmtPct(pct(area, TOTAL_AREA))}</strong><span>der Landfläche ({num(Math.round(area))} km²)</span></div>
<div className="stat-tile"><strong>{cities.length}</strong><span>{cities.length === 1 ? 'Stadt' : 'Städte'} besucht · {wishes} auf der Wunschliste</span></div>
<div className="stat-tile">
<strong>{route.legs.length ? `${num(Math.round(route.km))} km` : '–'}</strong>
<span>{route.legs.length ? `Luftlinie · ${summary}` : 'Besuche mit Datum eintragen, um Kilometer zu sehen'}</span>
</div>
</div>
<section className="stat-home" aria-labelledby="stat-wohnort">
<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>
)}
<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>
<ol>
{route.legs.map((l, i) => (
<li key={i}>
<span>{l.from} → {l.to}</span>
<small>{l.kind}</small>
<strong>{num(Math.round(l.km))} km</strong>
</li>
))}
</ol>
{!homes.length && <p className="hint">Ohne Wohnort fehlen Hin- und Heimwege; die Reisen werden direkt nacheinander verbunden.</p>}
</details>
)}
</section>
<section className="stat-continents" aria-labelledby="stat-kontinente">
<h2 className="section-title" id="stat-kontinente">Kontinente</h2>
{continents.map((c) => (
<div key={c.name} className="meter">
<div className="meter-label"><span>{c.name}</span><strong>{c.seen} / {c.total} · {fmtPct(pct(c.seen, c.total))}</strong></div>
<span className="meter-bar"><i style={{ width: `${pct(c.seen, c.total)}%` }} /></span>
</div>
))}
</section>
{cities.length > 0 && (
<section aria-labelledby="stat-punkte">
<h2 className="section-title" id="stat-punkte">Äußerste Punkte</h2>
<dl className="info-facts stat-points">
{points.map(([label, city, fmt]) => (
<div key={label}>
<dt>{label}</dt>
<dd>
<button type="button" className="link-btn" onClick={() => onSelectCity(city)}>
{BY_CODE.get(city.country)?.flag} {city.name}
</button>
<small> {countryName(city.country)} · {fmt(city)}</small>
</dd>
</div>
))}
</dl>
</section>
)}
</div>
);
}