modified: app.py

modified:   static/css/style.css
	modified:   templates/base.html
	modified:   templates/index.html
	modified:   templates/weather.html
	new file:   translations.py
This commit is contained in:
simon
2026-04-27 11:06:16 +02:00
parent e793804cea
commit 2b671b10ff
6 changed files with 285 additions and 57 deletions

31
app.py
View File

@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
from flask import Flask, render_template, request, jsonify
from flask import Flask, render_template, request, jsonify, redirect, make_response, g
from translations import TRANSLATIONS
from geopy.geocoders import Nominatim
from geopy.exc import GeocoderTimedOut
import math
@@ -19,6 +20,18 @@ import requests as _requests
app = Flask(__name__)
app.logger.setLevel(logging.INFO)
@app.before_request
def _set_language():
lang = request.cookies.get("lang", "de")
if lang not in TRANSLATIONS:
lang = "de"
g.lang = lang
g.T = TRANSLATIONS[lang]
@app.context_processor
def _inject_i18n():
return {"T": g.get("T", TRANSLATIONS["de"]), "lang": g.get("lang", "de")}
_GEOLOCATOR = Nominatim(user_agent="skywatcher-app/1.0")
# ── Zeitzone ────────────────────────────────────────────────────────────
@@ -810,6 +823,16 @@ def filter_unrealistic_warnings(warnings, forecast, now_local=None):
return result
@app.route("/set-lang")
def set_lang():
lang = request.args.get("lang", "de")
if lang not in TRANSLATIONS:
lang = "de"
dest = request.args.get("next") or request.referrer or "/"
resp = make_response(redirect(dest))
resp.set_cookie("lang", lang, max_age=60*60*24*365, samesite="Lax")
return resp
@app.route("/")
def index():
return render_template("index.html")
@@ -840,14 +863,14 @@ def wetter():
if lat is None:
if not ort:
return render_template("index.html", error="Bitte einen Ort eingeben.")
return render_template("index.html", error=g.T["error_no_place"])
lat, lon, display_name, state_hint, location_names = geocode_location(ort)
if lat is None:
return render_template("index.html", error=f'Ort "{ort}" konnte nicht gefunden werden.')
return render_template("index.html", error=g.T["error_place_not_found"].format(ort=ort))
loc_tz, location_tz = _get_location_tz(lat, lon)
forecast, mosmix_station = get_mosmix_forecast(lat, lon, hours=240, loc_tz=loc_tz)
if not forecast:
return render_template("index.html", error="Keine Wetterdaten verfügbar. Bitte später erneut versuchen.")
return render_template("index.html", error=g.T["error_no_data"])
station_name = mosmix_station.get("name", ort)
station_id = mosmix_station.get("station_id", "")
station_lat = float(mosmix_station.get("latitude", lat))

View File

@@ -61,6 +61,24 @@ main { flex: 1; }
letter-spacing: 0.02em;
white-space: nowrap;
}
.lang-switch {
display: flex; align-items: center; gap: 0.15rem;
flex-shrink: 0;
}
.lang-btn {
font-size: 0.78rem; font-weight: 600;
padding: 0.2rem 0.45rem;
border-radius: 5px;
color: var(--muted);
text-decoration: none;
letter-spacing: 0.04em;
transition: color 0.15s, background 0.15s;
}
.lang-btn:hover { color: var(--text); }
.lang-btn--active {
color: var(--orange);
background: rgba(255,140,50,0.12);
}
.nav-search-wrap { position: relative; display: flex; align-items: center; }
.nav-search-icon {
position: absolute; left: 10px; width: 16px; height: 16px;

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="de">
<html lang="{{ lang }}">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
@@ -15,17 +15,21 @@
<form class="nav-search" action="/wetter" method="get" autocomplete="off">
<div class="nav-search-wrap">
<svg class="nav-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<input type="text" name="ort" id="nav-ort" placeholder="Ort suchen …" value="{{ request.args.get('ort','') }}"/>
<input type="text" name="ort" id="nav-ort" placeholder="{{ T.nav_placeholder }}" value="{{ request.args.get('ort','') }}"/>
<ul class="ac-list" id="nav-ac"></ul>
</div>
</form>
<span class="nav-clock" id="nav-clock"></span>
<span class="nav-clock" id="nav-clock" data-suffix="{{ T.clock_suffix }}"></span>
<nav class="lang-switch" aria-label="Language">
<a href="/set-lang?lang=de&next={{ request.full_path }}" class="lang-btn{% if lang == 'de' %} lang-btn--active{% endif %}" hreflang="de">DE</a>
<a href="/set-lang?lang=en&next={{ request.full_path }}" class="lang-btn{% if lang == 'en' %} lang-btn--active{% endif %}" hreflang="en">EN</a>
</nav>
</nav>
<main>{% block content %}{% endblock %}</main>
<footer class="footer">
Wetterdaten: <a href="https://opendata.dwd.de" target="_blank" rel="noopener noreferrer">Deutscher Wetterdienst Open Data (MOSMIX)</a>
{{ T.data_source }} <a href="https://opendata.dwd.de" target="_blank" rel="noopener noreferrer">Deutscher Wetterdienst Open Data (MOSMIX)</a>
</footer>
<script>
@@ -73,6 +77,7 @@ window.setupAC = setupAC;
const tz = tzMeta ? tzMeta.getAttribute("content") : null;
const navClock = document.getElementById("nav-clock");
const heroTime = document.getElementById("hero-time");
const suffix = navClock ? (navClock.dataset.suffix ? " " + navClock.dataset.suffix : "") : "";
function tick() {
const now = new Date();
@@ -83,8 +88,8 @@ window.setupAC = setupAC;
} catch (e) {
timeStr = new Intl.DateTimeFormat("de-DE", opts).format(now);
}
if (navClock) navClock.textContent = timeStr + " Uhr";
if (heroTime) heroTime.textContent = timeStr + " Uhr";
if (navClock) navClock.textContent = timeStr + suffix;
if (heroTime) heroTime.textContent = timeStr + suffix;
}
tick();

View File

@@ -1,24 +1,24 @@
{% extends "base.html" %}
{% block title %}Skywatcher Wetter für Deutschland{% endblock %}
{% block title %}{{ T.page_title_home }}{% endblock %}
{% block content %}
<div class="home-wrap">
<div class="home-bg-text" aria-hidden="true">WETTER</div>
<div class="home-center">
<p class="home-eyebrow">Präzise Vorhersagen · Daten via DWD Open Data</p>
<h1 class="home-title">Wo willst du<br>das Wetter wissen?</h1>
<p class="home-eyebrow">{{ T.home_eyebrow }}</p>
<h1 class="home-title">{{ T.home_headline|safe }}</h1>
<form class="home-form" action="/wetter" method="get" autocomplete="off">
<div class="home-input-wrap">
<svg class="home-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<input type="text" name="ort" id="home-ort" placeholder="Stadt, Gemeinde oder PLZ …" required autofocus/>
<input type="text" name="ort" id="home-ort" placeholder="{{ T.home_placeholder }}" required autofocus/>
<ul class="ac-list" id="home-ac"></ul>
</div>
<button type="submit" class="home-btn">
Abrufen
{{ T.home_btn }}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</form>
@@ -30,9 +30,9 @@
<a href="/wetter?ort={{ city }}" class="chip">{{ city }}</a>
{% endfor %}
</div>
<button class="btn-location" id="btn-location" title="Meinen Standort nutzen">
<button class="btn-location" id="btn-location" title="{{ T.btn_location }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3"/><circle cx="12" cy="12" r="9" opacity=".3"/></svg>
Meinen Standort nutzen
{{ T.btn_location }}
</button>
</div>
@@ -49,14 +49,20 @@
{% block scripts %}
<script>
const _i18n = {
locating: {{ T.locating | tojson }},
locationUnavailable: {{ T.location_unavailable | tojson }},
geoUnsupported: {{ T.geolocation_unsupported | tojson }},
recentlySearched: {{ T.recently_searched | tojson }},
};
// Standort-Button
document.getElementById("btn-location").addEventListener("click", function() {
if (!navigator.geolocation) { alert("Geolocation nicht unterstützt."); return; }
this.textContent = "Wird ermittelt …";
if (!navigator.geolocation) { alert(_i18n.geoUnsupported); return; }
this.textContent = _i18n.locating;
this.disabled = true;
navigator.geolocation.getCurrentPosition(
pos => { window.location = `/wetter?lat=${pos.coords.latitude}&lon=${pos.coords.longitude}&ort=Mein+Standort`; },
() => { this.textContent = "Standort nicht verfügbar"; this.disabled = false; }
() => { this.textContent = _i18n.locationUnavailable; this.disabled = false; }
);
});
@@ -67,7 +73,7 @@ document.getElementById("btn-location").addEventListener("click", function() {
const wrap = document.getElementById("home-chips");
const div = document.createElement("div");
div.className = "recent-label";
div.textContent = "Zuletzt gesucht:";
div.textContent = _i18n.recentlySearched;
wrap.before(div);
const strip = document.createElement("div");
strip.className = "home-chips home-chips--recent";

View File

@@ -23,7 +23,7 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
{{ display_name.split(',')[0] }}
<span class="hero-meta-sub">{{ display_name.split(',')[1:3]|join(',') if ',' in display_name else '' }}</span>
<span class="hero-meta-time" id="hero-time">{{ now_local }} Uhr</span>
<span class="hero-meta-time" id="hero-time">{{ now_local }}{% if T.clock_suffix %} {{ T.clock_suffix }}{% endif %}</span>
</div>
<div class="hero-main">
@@ -39,7 +39,7 @@
<span>{{ current.wind_kmh }} km/h {{ wind_dir_name(current.wind_dir) }}</span>
{% endif %}
{% if current.cloud_pct is not none %}
<span>{{ current.cloud_pct }}% Bedeckung</span>
<span>{{ current.cloud_pct }}% {{ T.cloud_cover }}</span>
{% endif %}
{% if current.pressure_hpa is not none %}
<span>{{ current.pressure_hpa }} hPa</span>
@@ -56,11 +56,11 @@
{% set uv_curr_str = "" %}
{% endif %}
{% set items = [
("Gefühlt wie", (current.feels_like|string + " °C") if current.feels_like is not none else ""),
("Böen", (current.gust_kmh|string + " km/h") if current.gust_kmh is not none else ""),
("Niederschlag", (current.precip_mm|string + " mm") if (current.precip_mm is not none and current.precip_mm > 0) else ((current.rain_prob|string + " %") if (current.rain_prob is not none and current.rain_prob > 0) else "0 mm")),
("Sonne", (current.sun_min|string + " min/h") if (current.sun_min is not none and current.sun_min > 0) else ""),
("UV-Index", uv_curr_str),
(T.feels_like, (current.feels_like|string + " °C") if current.feels_like is not none else ""),
(T.gusts, (current.gust_kmh|string + " km/h") if current.gust_kmh is not none else ""),
(T.precipitation, (current.precip_mm|string + " mm") if (current.precip_mm is not none and current.precip_mm > 0) else ((current.rain_prob|string + " %") if (current.rain_prob is not none and current.rain_prob > 0) else "0 mm")),
(T.sun, (current.sun_min|string + " min/h") if (current.sun_min is not none and current.sun_min > 0) else ""),
(T.uv_index, uv_curr_str),
] %}
{% for label, val in items %}
<div class="hero-metric">
@@ -72,7 +72,7 @@
</div>
<div class="hero-station">
📡 Station <strong>{{ station_name }}</strong> ({{ station_dist }} km)
📡 {{ T.station }} <strong>{{ station_name }}</strong> ({{ station_dist }} km)
{% if sunrise %}
&nbsp;·&nbsp; 🌅 {{ sunrise }} &nbsp;·&nbsp; 🌇 {{ sunset }}
{% endif %}
@@ -101,7 +101,7 @@
<p class="warn-desc">{{ short_desc }}{% if short_desc|length < w.description|length %}.{% endif %}</p>
{% if short_desc|length < w.description|length %}
<details class="warn-more">
<summary>Details</summary>
<summary>{{ T.warn_details }}</summary>
<p>{{ w.description }}</p>
</details>
{% endif %}
@@ -113,33 +113,33 @@
{% endif %}
<section class="section insights">
<h2 class="section-title">Kurzfazit</h2>
<h2 class="section-title">{{ T.section_summary }}</h2>
<div class="insight-grid">
<div class="insight-card">
<span class="insight-label">Temperaturtrend (6h)</span>
<span class="insight-label">{{ T.temp_trend_label }}</span>
<strong class="insight-value">
{% if temp_delta_6h is not none %}
{% if temp_delta_6h > 0 %}+{% endif %}{{ temp_delta_6h }}° · {{ temp_trend_6h }}
{% if temp_delta_6h > 0 %}+{% endif %}{{ temp_delta_6h }}° · {{ T.get(temp_trend_6h, temp_trend_6h) }}
{% else %}
{% endif %}
</strong>
</div>
<div class="insight-card">
<span class="insight-label">Drucktrend (6h)</span>
<span class="insight-label">{{ T.pressure_trend_label }}</span>
<strong class="insight-value">
{% if pressure_delta is not none %}
{% if pressure_delta > 0 %}+{% endif %}{{ pressure_delta }} hPa · {{ pressure_trend }}
{% if pressure_delta > 0 %}+{% endif %}{{ pressure_delta }} hPa · {{ T.get(pressure_trend, pressure_trend) }}
{% else %}
{% endif %}
</strong>
</div>
<div class="insight-card">
<span class="insight-label">Bestes Aktivitätsfenster</span>
<span class="insight-label">{{ T.best_window_label }}</span>
<strong class="insight-value">
{% if best_window %}
{{ best_window.start.strftime('%H:%M') }}{{ best_window.end.strftime('%H:%M') }} · Score {{ best_window.score }}
{{ best_window.start.strftime('%H:%M') }}{{ best_window.end.strftime('%H:%M') }} · {{ T.score }} {{ best_window.score }}
{% else %}
{% endif %}
@@ -150,23 +150,23 @@
<!-- STÜNDLICH -->
<section class="section">
<h2 class="section-title">Stundenweise</h2>
<h2 class="section-title">{{ T.section_hourly }}</h2>
<div class="hourly-legend">
<span class="hl-label">Legende:</span>
<span class="hl-badge hl-conf hl-conf--hoch">Konfidenz hoch</span>
<span class="hl-badge hl-conf hl-conf--mittel">mittel</span>
<span class="hl-badge hl-conf hl-conf--niedrig">niedrig</span>
<span class="hl-label">{{ T.legend }}</span>
<span class="hl-badge hl-conf hl-conf--hoch">{{ T.conf_high }}</span>
<span class="hl-badge hl-conf hl-conf--mittel">{{ T.conf_mid }}</span>
<span class="hl-badge hl-conf hl-conf--niedrig">{{ T.conf_low }}</span>
<span class="hl-sep">·</span>
<span class="hl-badge hl-act">Aktivität 0100</span>
<span class="hl-badge hl-act">{{ T.activity_legend }}</span>
<span class="hl-sep">·</span>
<span class="hl-badge hl-uv">UV-Index</span>
<span class="hl-badge hl-uv">{{ T.uv_index }}</span>
</div>
<div class="hourly-strip-wrap">
<div class="hourly-strip">
{% for h in forecast %}
<div class="hcard {% if loop.first %}hcard--now{% endif %}">
<div class="hcard-time">
{% if loop.first %}Jetzt
{% if loop.first %}{{ T.now }}
{% elif h.datetime is string %}{{ h.datetime[11:16] }}
{% else %}{{ h.datetime.strftime('%H:%M') }}{% endif %}
</div>
@@ -193,9 +193,9 @@
<div class="hcard-wind">{{ h.wind_kmh }}<small>km/h</small></div>
{% endif %}
<div class="hcard-confidence hcard-confidence--{{ h.confidence_label }}">{{ h.confidence }}%</div>
<div class="hcard-activity">Aktiv {{ h.activity_score }}</div>
<div class="hcard-activity">{{ T.active }} {{ h.activity_score }}</div>
{% if h.uv_index is not none and h.uv_index > 0 %}
<div class="hcard-uv hcard-uv--{{ h.uv_level }}">UV {{ h.uv_index }} {{ h.uv_label }}</div>
<div class="hcard-uv hcard-uv--{{ h.uv_level }}">UV {{ h.uv_index }} {{ T.get(h.uv_label, h.uv_label) }}</div>
{% endif %}
</div>
{% endfor %}
@@ -206,8 +206,8 @@
<!-- CHART -->
<section class="section">
<div class="chart-head">
<h2 class="section-title">Temperatur, Gefühlt &amp; Niederschlag</h2>
<span class="chart-range">72 Stunden</span>
<h2 class="section-title">{{ T.section_chart }}</h2>
<span class="chart-range">{{ T.chart_range }}</span>
</div>
<div class="chart-box">
<canvas id="wxChart"></canvas>
@@ -216,11 +216,11 @@
<!-- TAGESÜBERSICHT -->
<section class="section">
<h2 class="section-title">Tagesübersicht</h2>
<h2 class="section-title">{{ T.section_daily }}</h2>
<div class="daily-header">
<span>Tag</span>
<span>Temperaturbereich</span>
<span>Min &nbsp;·&nbsp; Max &nbsp;·&nbsp; Nieder. &nbsp;·&nbsp; UV</span>
<span>{{ T.daily_hdr_day }}</span>
<span>{{ T.daily_hdr_range }}</span>
<span>{{ T.daily_hdr_details }}</span>
</div>
<div class="daily-list">
{# calc global min/max for bar scaling do it in a loop to stay Jinja2-safe #}
@@ -282,7 +282,7 @@
</section>
<p class="data-note">
Wetterdaten: <a href="https://opendata.dwd.de" target="_blank" rel="noopener noreferrer">Deutscher Wetterdienst Open Data (MOSMIX)</a>
{{ T.data_source }} <a href="https://opendata.dwd.de" target="_blank" rel="noopener noreferrer">Deutscher Wetterdienst Open Data (MOSMIX)</a>
</p>
{% endblock %}
@@ -308,7 +308,7 @@
// Prüfen ob echte Niederschlagsmengen vorhanden sind
const hasRealPrecip = precip.some(v => v > 0);
const barData = hasRealPrecip ? precip : rainProb;
const barLabel = hasRealPrecip ? "Niederschlag (mm)" : "Regenwahrsch. (%)";
const barLabel = hasRealPrecip ? {{ T.chart_precip | tojson }} : {{ T.chart_rainprob | tojson }};
const barMax = hasRealPrecip ? undefined : 100;
// Nur jeden 3. Label anzeigen, Rest leer lassen
@@ -327,7 +327,7 @@
datasets: [
{
type: "line",
label: "Temperatur (°C)",
label: {{ T.chart_temp | tojson }},
data: temps,
borderColor: "#ff8c32",
backgroundColor: grad,
@@ -341,7 +341,7 @@
},
{
type: "line",
label: "Gefühlt (°C)",
label: {{ T.chart_feels | tojson }},
data: feels,
borderColor: "#ffd4a8",
borderDash: [5, 4],

176
translations.py Normal file
View File

@@ -0,0 +1,176 @@
# -*- coding: utf-8 -*-
"""UI translation strings for Skywatcher (de / en)."""
TRANSLATIONS = {
"de": {
# ── Nav / global ───────────────────────────────────────────
"nav_placeholder": "Ort suchen …",
"clock_suffix": "Uhr",
# ── Index page ─────────────────────────────────────────────
"page_title_home": "Skywatcher Wetter für Deutschland",
"home_eyebrow": "Präzise Vorhersagen · Daten via DWD Open Data",
"home_headline": "Wo willst du<br>das Wetter wissen?",
"home_placeholder": "Stadt, Gemeinde oder PLZ …",
"home_btn": "Abrufen",
"btn_location": "Meinen Standort nutzen",
"locating": "Wird ermittelt …",
"location_unavailable": "Standort nicht verfügbar",
"geolocation_unsupported": "Geolocation nicht unterstützt.",
"recently_searched": "Zuletzt gesucht:",
# ── Error messages ─────────────────────────────────────────
"error_no_place": "Bitte einen Ort eingeben.",
"error_place_not_found": "Ort \"{ort}\" konnte nicht gefunden werden.",
"error_no_data": "Keine Wetterdaten verfügbar. Bitte später erneut versuchen.",
# ── Hero ───────────────────────────────────────────────────
"cloud_cover": "Bedeckung",
"feels_like": "Gefühlt wie",
"gusts": "Böen",
"precipitation": "Niederschlag",
"sun": "Sonne",
"uv_index": "UV-Index",
"station": "Station",
# ── Warnings ───────────────────────────────────────────────
"warn_details": "Details",
# ── Insights ───────────────────────────────────────────────
"section_summary": "Kurzfazit",
"temp_trend_label": "Temperaturtrend (6h)",
"pressure_trend_label": "Drucktrend (6h)",
"best_window_label": "Bestes Aktivitätsfenster",
"score": "Score",
# ── Hourly strip ───────────────────────────────────────────
"section_hourly": "Stundenweise",
"legend": "Legende:",
"conf_high": "Konfidenz hoch",
"conf_mid": "mittel",
"conf_low": "niedrig",
"activity_legend": "Aktivität 0100",
"now": "Jetzt",
"active": "Aktiv",
# ── Chart ──────────────────────────────────────────────────
"section_chart": "Temperatur, Gefühlt &amp; Niederschlag",
"chart_range": "72 Stunden",
"chart_temp": "Temperatur (°C)",
"chart_feels": "Gefühlt (°C)",
"chart_precip": "Niederschlag (mm)",
"chart_rainprob": "Regenwahrsch. (%)",
# ── Daily ──────────────────────────────────────────────────
"section_daily": "Tagesübersicht",
"daily_hdr_day": "Tag",
"daily_hdr_range": "Temperaturbereich",
"daily_hdr_details": "Min · Max · Nieder. · UV",
# ── Footer ─────────────────────────────────────────────────
"data_source": "Wetterdaten:",
# ── Dynamic back-end label translations ───────────────────
# confidence
"hoch": "hoch",
"mittel": "mittel",
"niedrig": "niedrig",
# UV
"moderat": "moderat",
"sehr hoch": "sehr hoch",
"extrem": "extrem",
# pressure / temp trend
"steigend": "steigend",
"fallend": "fallend",
"stabil": "stabil",
"wärmer": "wärmer",
"kälter": "kälter",
"konstant": "konstant",
},
"en": {
# ── Nav / global ───────────────────────────────────────────
"nav_placeholder": "Search location …",
"clock_suffix": "",
# ── Index page ─────────────────────────────────────────────
"page_title_home": "Skywatcher Weather Forecast",
"home_eyebrow": "Precise forecasts · Data via DWD Open Data",
"home_headline": "Where do you want<br>to know the weather?",
"home_placeholder": "City, municipality or ZIP …",
"home_btn": "Get forecast",
"btn_location": "Use my location",
"locating": "Locating …",
"location_unavailable": "Location unavailable",
"geolocation_unsupported": "Geolocation not supported.",
"recently_searched": "Recently searched:",
# ── Error messages ─────────────────────────────────────────
"error_no_place": "Please enter a location.",
"error_place_not_found": "Location \"{ort}\" could not be found.",
"error_no_data": "No weather data available. Please try again later.",
# ── Hero ───────────────────────────────────────────────────
"cloud_cover": "Cloud cover",
"feels_like": "Feels like",
"gusts": "Gusts",
"precipitation": "Precipitation",
"sun": "Sun",
"uv_index": "UV index",
"station": "Station",
# ── Warnings ───────────────────────────────────────────────
"warn_details": "Details",
# ── Insights ───────────────────────────────────────────────
"section_summary": "Summary",
"temp_trend_label": "Temperature trend (6h)",
"pressure_trend_label": "Pressure trend (6h)",
"best_window_label": "Best activity window",
"score": "Score",
# ── Hourly strip ───────────────────────────────────────────
"section_hourly": "Hourly",
"legend": "Legend:",
"conf_high": "Confidence high",
"conf_mid": "medium",
"conf_low": "low",
"activity_legend": "Activity 0100",
"now": "Now",
"active": "Active",
# ── Chart ──────────────────────────────────────────────────
"section_chart": "Temperature, Feels like &amp; Precipitation",
"chart_range": "72 hours",
"chart_temp": "Temperature (°C)",
"chart_feels": "Feels like (°C)",
"chart_precip": "Precipitation (mm)",
"chart_rainprob": "Rain prob. (%)",
# ── Daily ──────────────────────────────────────────────────
"section_daily": "Daily overview",
"daily_hdr_day": "Day",
"daily_hdr_range": "Temperature range",
"daily_hdr_details": "Min · Max · Precip. · UV",
# ── Footer ─────────────────────────────────────────────────
"data_source": "Weather data:",
# ── Dynamic back-end label translations ───────────────────
# confidence
"hoch": "high",
"mittel": "medium",
"niedrig": "low",
# UV
"moderat": "moderate",
"sehr hoch": "very high",
"extrem": "extreme",
# pressure / temp trend
"steigend": "rising",
"fallend": "falling",
"stabil": "stable",
"wärmer": "warmer",
"kälter": "colder",
"konstant": "steady",
},
}