Reisen: Wohnort und nachvollziehbare Kilometer-Rechnung
- Wohnort pro Konto (Tabelle travel_home), im Tab "Statistik" über die Städtesuche festlegen, auf der Karte als ⌂ - Kilometer (route.js): jede Reise mit Hin- und Heimweg ab Wohnort; Reisen mit höchstens einem Tag Abstand als Rundreise; Besuche während eines längeren Aufenthalts als Ausflug hin und zurück; ohne Wohnort werden Reisen direkt verbunden - "Wie gerechnet?" listet alle Teilstrecken; Reisetage zählen jeden Tag nur einmal (Ausflüge im Aufenthalt nicht doppelt) - Wohnort-Suche mit eigenen IDs (zwei Suchfelder auf der Seite) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fdbb31065f
commit
56c4f38ef0
10 files changed
+210
-22
No files matched your search
@@ -199,8 +199,10 @@ Texte aus Wikipedia, Einwohner aus Wikidata, Orte über Open-Meteo/GeoNames).
|
||||
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).
|
||||
- **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) entlang der
|
||||
Besuche in zeitlicher Reihenfolge.
|
||||
nördlichster/südlichster/östlichster/westlichster Ort und die Kilometer (Luftlinie). Mit **Wohnort**
|
||||
(im Tab „Statistik“ festlegen, erscheint als ⌂ auf der Karte) zählt jede Reise mit Hin- und Heimweg;
|
||||
Reisen mit höchstens einem Tag Abstand gelten als Rundreise, Besuche während eines längeren Aufenthalts
|
||||
als Ausflug hin und zurück. „Wie gerechnet?“ listet alle Teilstrecken. Reisetage zählen jeden Tag einmal.
|
||||
|
||||
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
|
||||
|
||||
@@ -232,6 +232,16 @@ CREATE TABLE IF NOT EXISTS travel_visits (
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Wohnort pro Konto: Start und Ziel jeder Reise für die Kilometer-Statistik
|
||||
CREATE TABLE IF NOT EXISTS travel_home (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
geoname_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
country TEXT NOT NULL,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS travel_visit_photos (
|
||||
visit_id INTEGER NOT NULL REFERENCES travel_visits(id) ON DELETE CASCADE,
|
||||
file TEXT NOT NULL, -- Datei in data/uploads (ohne Metadaten gespeichert)
|
||||
|
||||
@@ -90,9 +90,11 @@ def _visits(conn, user_id: int) -> list:
|
||||
def _state(conn, user_id: int) -> dict:
|
||||
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()
|
||||
home = conn.execute("SELECT * FROM travel_home WHERE user_id = ?", (user_id,)).fetchone()
|
||||
return {
|
||||
"cities": [_city(r) for r in cities],
|
||||
"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,
|
||||
"visits": _visits(conn, user_id),
|
||||
"others": _others(conn, user_id),
|
||||
}
|
||||
@@ -178,6 +180,39 @@ def update_city(city_id: int, data: dict = Body(...), user: dict = Depends(curre
|
||||
return _state(conn, user["id"])
|
||||
|
||||
|
||||
# ---------- Wohnort ----------
|
||||
|
||||
|
||||
@router.put("/api/reisen/wohnort")
|
||||
def set_home(data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""Wohnort festlegen (Ergebnis der Städtesuche); nur für die eigene Kilometer-Statistik."""
|
||||
geoname_id = data.get("geonameId")
|
||||
if isinstance(geoname_id, bool) or not isinstance(geoname_id, int) or geoname_id <= 0:
|
||||
raise ValidationError("Ungültige Stadt.")
|
||||
country = data.get("country")
|
||||
if not isinstance(country, str) or not re.fullmatch(r"[A-Z]{2}", country):
|
||||
raise ValidationError("Ungültiges Land.")
|
||||
values = (
|
||||
user["id"], geoname_id, clean_text(data.get("name"), "Name", 100), country,
|
||||
_number(data.get("lat"), "Breitengrad", -90, 90), _number(data.get("lon"), "Längengrad", -180, 180),
|
||||
)
|
||||
with transaction() as conn:
|
||||
conn.execute(
|
||||
"""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"])
|
||||
|
||||
|
||||
@router.delete("/api/reisen/wohnort")
|
||||
def delete_home(user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
conn.execute("DELETE FROM travel_home WHERE user_id = ?", (user["id"],))
|
||||
return _state(conn, user["id"])
|
||||
|
||||
|
||||
# ---------- Besuche: Zeitraum, Begleitung, Notizen, Fotos ----------
|
||||
|
||||
DATE = re.compile(r"\d{4}-\d{2}-\d{2}")
|
||||
|
||||
@@ -112,3 +112,13 @@ def test_reisefotos_ueberleben_aufraeumen(login):
|
||||
os.utime(DATA_DIR / "uploads" / f, (old, old))
|
||||
projects._cleanup_orphans()
|
||||
assert (DATA_DIR / "uploads" / f).exists()
|
||||
|
||||
|
||||
def test_wohnort_setzen_und_entfernen(login):
|
||||
anna, ben = login("anna"), login("ben")
|
||||
berlin = {k: BERLIN[k] for k in ("geonameId", "name", "country", "lat", "lon")}
|
||||
data = anna.put("/api/reisen/wohnort", json=berlin).json()
|
||||
assert data["home"] == berlin
|
||||
assert ben.get("/api/reisen").json()["home"] is None # nur das eigene Konto
|
||||
assert anna.put("/api/reisen/wohnort", json={**berlin, "lat": 99}).status_code == 400
|
||||
assert anna.delete("/api/reisen/wohnort").json()["home"] is None
|
||||
@@ -4,8 +4,10 @@ import { BY_CODE, people } from './laender.js';
|
||||
|
||||
// Stadt suchen (Open-Meteo über den Server) und hinzufügen; schon eingetragene sind markiert
|
||||
|
||||
export default function CitySearch({ cities, onAdd, busy }) {
|
||||
// mode: 'add' (Stadt besucht/Wunschliste hinzufügen) oder 'home' (Wohnort wählen)
|
||||
export default function CitySearch({ cities, onAdd, busy, mode = 'add' }) {
|
||||
const [wish, setWish] = useState(false); // Besucht oder Wunschliste
|
||||
const ids = mode === 'home' ? 'home-search' : 'city-search'; // eindeutig, auch wenn beide Suchen auf der Seite sind
|
||||
const [q, setQ] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -56,33 +58,35 @@ export default function CitySearch({ cities, onAdd, busy }) {
|
||||
|
||||
return (
|
||||
<div className="city-search-row">
|
||||
{mode === 'add' && (
|
||||
<div className="sort-toggle" role="group" aria-label="Hinzufügen als">
|
||||
<button type="button" aria-pressed={!wish} onClick={() => setWish(false)}>Besucht</button>
|
||||
<button type="button" aria-pressed={wish} onClick={() => setWish(true)}>☆ Wunschliste</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="city-search combo">
|
||||
<label className="sr-only" htmlFor="city-search">Stadt hinzufügen</label>
|
||||
<label className="sr-only" htmlFor={ids}>Stadt hinzufügen</label>
|
||||
<div className="prompt-input">
|
||||
<input
|
||||
id="city-search"
|
||||
id={ids}
|
||||
value={q}
|
||||
onChange={(e) => { setQ(e.target.value); setOpen(true); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={wish ? 'Stadt auf die Wunschliste setzen …' : 'Stadt hinzufügen, in der du schon warst …'}
|
||||
placeholder={mode === 'home' ? 'Wohnort suchen …' : wish ? 'Stadt auf die Wunschliste setzen …' : 'Stadt hinzufügen, in der du schon warst …'}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={show && results.length > 0}
|
||||
aria-controls="city-results"
|
||||
aria-activedescendant={show && active >= 0 ? `city-result-${active}` : undefined}
|
||||
aria-controls={`${ids}-results`}
|
||||
aria-activedescendant={show && active >= 0 ? `${ids}-result-${active}` : undefined}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
{show && (
|
||||
<ul className="combo-list" id="city-results" role="listbox">
|
||||
<ul className="combo-list" id={`${ids}-results`} role="listbox">
|
||||
{state === 'loading' && results.length === 0 && <li className="combo-info">Suche …</li>}
|
||||
{state === 'error' && <li className="combo-info">Suche gerade nicht erreichbar.</li>}
|
||||
{state === 'idle' && results.length === 0 && <li className="combo-info">Keine Stadt gefunden.</li>}
|
||||
@@ -91,7 +95,7 @@ export default function CitySearch({ cities, onAdd, busy }) {
|
||||
return (
|
||||
<li
|
||||
key={r.geonameId}
|
||||
id={`city-result-${i}`}
|
||||
id={`${ids}-result-${i}`}
|
||||
role="option"
|
||||
aria-selected={i === active}
|
||||
aria-disabled={added || undefined}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import LAENDER from '../../data/laender.json';
|
||||
import { BY_CODE, CONTINENTS, continentOf, countryName, distanceKm, num } from './laender.js';
|
||||
import CitySearch from './CitySearch.jsx';
|
||||
import { BY_CODE, CONTINENTS, continentOf, countryName, 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 })} %`;
|
||||
@@ -9,7 +11,7 @@ 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 }) {
|
||||
export default function TravelStats({ visited, cities, visits, wishes, home, onSetHome, onRemoveHome, busy, 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) => {
|
||||
@@ -27,12 +29,14 @@ export default function TravelStats({ visited, cities, visits, wishes, onSelectC
|
||||
['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);
|
||||
// Kilometer: Reisen ab Wohnort, Rundreisen und Ausflüge (siehe route.js)
|
||||
const route = travelRoute(visits, cities, home);
|
||||
const days = travelDays(visits);
|
||||
const summary = [
|
||||
`${route.trips} ${route.trips === 1 ? 'Reise' : 'Reisen'}${home ? ` ab ${home.name}` : ''}`,
|
||||
route.excursions && `${route.excursions} ${route.excursions === 1 ? 'Ausflug' : 'Ausflüge'}`,
|
||||
`${days} Reisetage`,
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
return (
|
||||
<div className="travel-stats">
|
||||
@@ -41,11 +45,39 @@ export default function TravelStats({ visited, cities, visits, wishes, onSelectC
|
||||
<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>
|
||||
<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">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>
|
||||
) : (
|
||||
<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} />
|
||||
{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>
|
||||
{!home && <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) => (
|
||||
|
||||
@@ -32,7 +32,7 @@ function mainPart(feature) {
|
||||
* Über `ref`: focusCountry(key, offset), focusCity(city, offset), zoomBy(f), reset().
|
||||
* offset: Ziel nach rechts oben schieben, weil unten links der Info-Kasten liegt.
|
||||
*/
|
||||
export default function WorldMap({ ref, countries, visited, cities, otherCities = [], selected, onSelect }) {
|
||||
export default function WorldMap({ ref, countries, visited, cities, otherCities = [], home, selected, onSelect }) {
|
||||
const box = useRef(null);
|
||||
const svgRef = useRef(null);
|
||||
const layer = useRef(null);
|
||||
@@ -133,6 +133,14 @@ export default function WorldMap({ ref, countries, visited, cities, otherCities
|
||||
<title>{c.name}</title>
|
||||
</path>
|
||||
))}
|
||||
{home && (() => {
|
||||
const p = projection([home.lon, home.lat]);
|
||||
return p && (
|
||||
<g transform={`translate(${p[0]},${p[1]})`}>
|
||||
<g className="map-home"><title>Wohnort: {home.name}</title><path d="M0 -6 L6 0 L4 0 L4 5 L-4 5 L-4 0 L-6 0 Z" /></g>
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
{[...otherCities, ...cities].map((city) => {
|
||||
const p = projection([city.lon, city.lat]);
|
||||
if (!p) return null;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// 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.
|
||||
// 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);
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
// Reisetage: jeder Kalendertag nur einmal (Ausflüge während eines Aufenthalts zählen nicht doppelt)
|
||||
export function travelDays(visits) {
|
||||
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);
|
||||
}
|
||||
return days.size;
|
||||
}
|
||||
@@ -205,6 +205,7 @@ export default function Reisen() {
|
||||
visited={visited}
|
||||
cities={own}
|
||||
otherCities={otherCities}
|
||||
home={data?.home}
|
||||
selected={selected}
|
||||
onSelect={(sel) => select(sel)}
|
||||
/>
|
||||
@@ -253,7 +254,17 @@ export default function Reisen() {
|
||||
<Timeline visits={visits} cities={own} onSelectCity={showCity} onEdit={openVisit} onPhoto={setZoom} />
|
||||
)}
|
||||
{data && tab === 'statistik' && (
|
||||
<TravelStats visited={visited} cities={cities} visits={visits} wishes={wishes.length} onSelectCity={showCity} />
|
||||
<TravelStats
|
||||
visited={visited}
|
||||
cities={cities}
|
||||
visits={visits}
|
||||
wishes={wishes.length}
|
||||
home={data.home}
|
||||
busy={busy}
|
||||
onSetHome={(result) => run('PUT', '/api/reisen/wohnort', result)}
|
||||
onRemoveHome={() => run('DELETE', '/api/reisen/wohnort')}
|
||||
onSelectCity={showCity}
|
||||
/>
|
||||
)}
|
||||
{data && tab === 'wunsch' && (
|
||||
wishes.length ? (
|
||||
|
||||
@@ -236,3 +236,16 @@
|
||||
.link-picker ul.visit-photos { display: flex; flex-direction: row; flex-wrap: wrap; gap: 8px; max-height: none; }
|
||||
.link-picker ul.visit-photos li { width: auto; padding: 0; border: 0; }
|
||||
.visit-hint { margin: -8px 0 0; }
|
||||
|
||||
/* Wohnort */
|
||||
.map-home { transform: scale(var(--inv)); pointer-events: none; }
|
||||
.map-home path { fill: var(--bright); stroke: var(--bg); stroke-width: 1.5; paint-order: stroke; }
|
||||
.stat-home { display: grid; gap: 10px; max-width: 760px; }
|
||||
.stat-home-current { display: flex; align-items: baseline; gap: 10px; margin: 0; }
|
||||
.stat-home-current small { color: var(--muted); }
|
||||
.stat-home .city-search-row { margin: 0; }
|
||||
.stat-route summary { cursor: pointer; color: var(--accent); }
|
||||
.stat-route ol { margin: 8px 0 0; padding-left: 1.6em; display: grid; gap: 2px; }
|
||||
.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; }
|
||||
Reference in new issue
Block a user