Files
p42.f-w/frontend/src/components/reisen/TravelStats.jsx
T
guyg1474andClaude Opus 5.5 bbf740603e Reisen: Wunschliste, Besuche mit Fotos, Zeitleiste und Statistik
- Wunschliste (Spalte travel_cities.wish): eigene Punktform auf der Karte,
  zählt nicht als besucht, für andere Konten unsichtbar; "War ich da ✓"
- Besuche je Stadt (Tabellen travel_visits, travel_visit_photos): Zeitraum,
  Begleitung, Notizen, Fotos (Upload ohne Metadaten); nur für das eigene
  Konto; ein Besuch macht aus einem Wunsch einen Besuch
- Fotos von Besuchen zählen beim Aufräumen verwaister Bilder als benutzt;
  beim Entfernen eines Fotos, Besuchs oder einer Stadt werden die Dateien
  gelöscht
- Zeitleiste "Wann war ich wo?" nach Jahren, Fotos mit Lightbox
- Statistik: Länder (Welt, je Kontinent), Landfläche, äußerste Orte,
  Kilometer entlang der Besuche
- Ansichten unter der Karte als Tabs: Besuchte Orte, Zeitleiste,
  Statistik, Wunschliste
- Migration Schritt 4, Tests für Wunschliste, Besuche, Fotos und Aufräumen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:25:07 +02:00

80 lines
4.3 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 LAENDER from '../../data/laender.json';
import { BY_CODE, CONTINENTS, continentOf, countryName, distanceKm, num } from './laender.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, onSelectCity }) {
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: Besuche in zeitlicher Reihenfolge, Luftlinie von Ort zu Ort
const byId = new Map(cities.map((c) => [c.id, c]));
const route = [...visits].sort((a, b) => a.dateFrom.localeCompare(b.dateFrom)).map((v) => byId.get(v.cityId)).filter(Boolean);
let km = 0;
for (let i = 1; i < route.length; i += 1) if (route[i].id !== route[i - 1].id) km += distanceKm(route[i - 1], route[i]);
const days = visits.reduce((s, v) => s + 1 + (v.dateTo ? Math.round((new Date(v.dateTo) - new Date(v.dateFrom)) / 864e5) : 0), 0);
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.length > 1 ? `${num(Math.round(km))} km` : '–'}</strong>
<span>{route.length > 1 ? `Luftlinie entlang ${visits.length} Besuchen (${days} Reisetage)` : 'Besuche mit Datum eintragen, um Kilometer zu sehen'}</span>
</div>
</div>
<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>
);
}