18 Commits

Author SHA1 Message Date
f5a39e80b4 Taschenrechner-Features hinzugefügt und README aktualisiert 2025-08-03 12:05:02 +02:00
f9f73e24c9 Fix Back-Forward-Cache Lighthouse error by adding proper Cache-Control headers 2025-08-03 10:18:00 +02:00
d697928241 Bump version to 1.4.10 2025-08-03 10:04:09 +02:00
f998f7fff8 Fix JSON syntax error in swagger.json - remove invalid .v{ prefix 2025-08-03 10:01:37 +02:00
1a5aa003a2 v1.4.9: Verbesserte Sprachausgabe mit englischer Unterstützung und Service Worker Fix 2025-08-02 19:54:52 +02:00
eecc2b8b73 Sprachausgabe/englisch gefixt 2025-08-02 19:52:31 +02:00
0b13a408cd Update meta keywords 2025-08-02 19:08:48 +02:00
c4a65bba48 Version 2025-08-02 18:39:30 +02:00
e4b37d9261 Fehler in der API behoben 2025-08-02 18:37:17 +02:00
45cc02b4b0 Fehler in API behoben 2025-08-02 18:30:16 +02:00
05766d9a97 Version auf 1.4.7 erhöht - Dashboard mit Toggle-Funktionalität 2025-08-02 14:28:22 +02:00
e5fbc14a34 Dashboard erweitert: Toggle zwischen Wochen- und 24-Stunden-Verlauf für alle Charts 2025-08-02 14:25:07 +02:00
9e025bd4c7 Überarbeite Help Modal: Floating Schließen-Button und mehrsprachige Unterstützung
- Schließen-Button ist jetzt 'floating' mit position: fixed
- Button hat Hintergrund, Rahmen und Schatten für bessere Sichtbarkeit
- Alle Texte im Help Modal verwenden jetzt Übersetzungsfunktionen
- Vollständige mehrsprachige Unterstützung (Deutsch/Englisch)
- Bessere mobile Darstellung ohne Überschneidungen
2025-08-02 14:17:46 +02:00
f4ffd14624 Update CLOC 2025-08-02 11:38:28 +02:00
4740288c45 Desktop-Layout: Sprachauswahl und Hilfe-Button etwas nach oben verschoben für bessere Balance 2025-08-02 11:31:30 +02:00
512898b34b Fix mobile layout: Verbesserte Lösung für Sprachauswahl-Überlappung mit mehr Abstand 2025-08-02 11:25:22 +02:00
872d0f9e23 Fix: Button 'Berechnen' wird jetzt korrekt als 'Calculate' in englischer Version übersetzt 2025-08-02 11:18:19 +02:00
28fda213ba Fix mobile layout: Sprachauswahl überlappt nicht mehr mit Überschrift und korrigiere URL-Parameter für Sprachwechsel 2025-08-02 11:12:37 +02:00
10 changed files with 2818 additions and 1638 deletions

View File

@@ -60,6 +60,7 @@ Die Webanwendung erreicht hervorragende Performance-Werte in allen Kategorien (P
- Datum plus/minus X Wochen/Monate - Datum plus/minus X Wochen/Monate
- Kalenderwoche zu Datum - Kalenderwoche zu Datum
- Start-/Enddatum einer Kalenderwoche eines Jahres - Start-/Enddatum einer Kalenderwoche eines Jahres
- **Integrierter Taschenrechner** mit History und Sprachausgabe
- Mehrsprachige Unterstützung (Deutsch/Englisch) mit automatischer Browser-Spracherkennung - Mehrsprachige Unterstützung (Deutsch/Englisch) mit automatischer Browser-Spracherkennung
- Sprachausgabe für alle Ergebnisse (barrierefrei) - Sprachausgabe für alle Ergebnisse (barrierefrei)
- Statistik-Dashboard mit Passwortschutz unter `/stats` - Statistik-Dashboard mit Passwortschutz unter `/stats`
@@ -109,6 +110,7 @@ Die Anwendung unterstützt Deutsch und Englisch mit folgenden Features:
- *Tastatur-Navigation*: Vollständig bedienbar - *Tastatur-Navigation*: Vollständig bedienbar
- *ARIA-Attribute*: Korrekte Beschriftungen - *ARIA-Attribute*: Korrekte Beschriftungen
- *Semantische HTML*: Korrekte Struktur - *Semantische HTML*: Korrekte Struktur
- *Taschenrechner*: Vollständig barrierefrei mit Tastatur-Bedienung und Sprachausgabe
### *Technische Details:* ### *Technische Details:*
- *Flask-Babel*: Professionelle i18n-Implementierung - *Flask-Babel*: Professionelle i18n-Implementierung
@@ -334,6 +336,7 @@ curl -X POST http://localhost:5000/api/plusminus \
``` ```
**Hinweis:** **Hinweis:**
- `"einheit"`: `"tage"`, `"wochen"` oder `"monate"` - `"einheit"`: `"tage"`, `"wochen"` oder `"monate"`
- `"richtung"`: `"add"` (plus) oder `"sub"` (minus) - `"richtung"`: `"add"` (plus) oder `"sub"` (minus)
- `"werktage"`: `true` für Werktage, sonst `false` (nur bei `"tage"` unterstützt) - `"werktage"`: `true` für Werktage, sonst `false` (nur bei `"tage"` unterstützt)
@@ -395,6 +398,7 @@ Elpatrons Datumsrechner ist als PWA installierbar (z.B. auf Android/iOS-Homescre
- Manifest und Service Worker sind integriert - Manifest und Service Worker sind integriert
- App-Icon und Theme-Color für Homescreen - App-Icon und Theme-Color für Homescreen
- Installation über Browser-Menü ("Zum Startbildschirm hinzufügen") - Installation über Browser-Menü ("Zum Startbildschirm hinzufügen")
- Taschenrechner funktioniert vollständig clientseitig (offline verfügbar)
## Monitoring & Healthcheck ## Monitoring & Healthcheck
@@ -448,7 +452,7 @@ Finde mal eine Datumsrechner- Webapp, die nicht völlig Werbe- und Tracking vers
### Vibe Coding ### Vibe Coding
Dieses Projekt wurde zu nahezu 100% mit Unterstützung künsticher Intelligenz (*[Vibe Coding](https://de.wikipedia.org/wiki/Vibe_Coding)*) erstellt. Das Grundgerüst war nach ca. 45 Minuten fertig gestellt, insgesamt hat die Entwicklung des Projekts ca. 4 Stunden Zeit beansprucht. Dieses Projekt wurde zu nahezu 100% mit Unterstützung künsticher Intelligenz (*[Vibe Coding](https://de.wikipedia.org/wiki/Vibe_Coding)*) erstellt. Das Grundgerüst war nach ca. 45 Minuten fertig gestellt, insgesamt hat die Entwicklung des Projekts ca. 12 Stunden Zeit beansprucht.
### Statistik-Erfassung, Logging ### Statistik-Erfassung, Logging
@@ -465,6 +469,7 @@ Es werden keine IP-Adressen oder sonstigen persönlichen Daten gespeichert, ledi
- *Farbkontraste:* Hohe Kontraste für Texte, Buttons und Ergebnisboxen, geprüft nach WCAG-Richtlinien. - *Farbkontraste:* Hohe Kontraste für Texte, Buttons und Ergebnisboxen, geprüft nach WCAG-Richtlinien.
- *Status- und Fehlermeldungen:* Ergebnisse und Fehler werden mit `aria-live` für Screenreader zugänglich gemacht. - *Status- und Fehlermeldungen:* Ergebnisse und Fehler werden mit `aria-live` für Screenreader zugänglich gemacht.
- *Sprachausgabe:* Alle Ergebnisse können über 🔊-Buttons vorgelesen werden (Web Speech API, deutsche Sprache). - *Sprachausgabe:* Alle Ergebnisse können über 🔊-Buttons vorgelesen werden (Web Speech API, deutsche Sprache).
- *Taschenrechner:* Vollständig barrierefrei mit Tastatur-Bedienung, Sprachausgabe und History-Funktion.
- *Mobile Optimierung:* Zusätzliche Meta-Tags für bessere Bedienbarkeit auf mobilen Geräten und Unterstützung von Screenreadern. - *Mobile Optimierung:* Zusätzliche Meta-Tags für bessere Bedienbarkeit auf mobilen Geräten und Unterstützung von Screenreadern.
- *SEO:* Das Thema Barrierefreiheit ist in den Meta-Tags für Suchmaschinen sichtbar. - *SEO:* Das Thema Barrierefreiheit ist in den Meta-Tags für Suchmaschinen sichtbar.
@@ -472,23 +477,23 @@ Damit ist die App für Menschen mit unterschiedlichen Einschränkungen (z.B. Seh
### Code Statistik ### Code Statistik
cloc|github.com/AlDanial/cloc v 2.06 T=0.08 s (301.6 files/s, 72354.1 lines/s) cloc|github.com/AlDanial/cloc v 2.06 T=0.22 s (114.3 files/s, 32032.3 lines/s)
--- | --- --- | ---
Language|files|blank|comment|code Language|files|blank|comment|code
:-------|-------:|-------:|-------:|-------: :-------|-------:|-------:|-------:|-------:
HTML|8|47|6|2086 HTML|8|159|8|2800
Python|2|59|68|690 Python|2|66|74|739
JavaScript|2|95|87|571 JavaScript|2|95|88|580
Markdown|3|176|0|492 PO File|2|260|266|544
PO File|2|234|240|492 Markdown|3|177|0|497
JSON|3|0|0|243 JSON|3|0|0|243
CSS|1|186|3|188 CSS|1|186|3|188
SVG|2|0|0|14 SVG|2|0|0|14
Dockerfile|1|5|6|8 Dockerfile|1|5|6|8
DOS Batch|1|0|0|1 DOS Batch|1|0|0|1
--------|--------|--------|--------|-------- --------|--------|--------|--------|--------
SUM:|25|802|410|4785 SUM:|25|948|445|5614
## Lizenz ## Lizenz
@@ -497,4 +502,4 @@ Dieses Projekt steht unter der [MIT-Lizenz](LICENSE).
--- ---
(c) 2025 [Markus Busche](https://digitalcourage.social/@elpatron) (c) 2025 [Markus Busche](https://digitalcourage.social/@elpatron)
**Version 1.4.0** - Mehrsprachige Unterstützung hinzugefügt **Version 1.4.12** - Integrierter Taschenrechner mit History und Sprachausgabe hinzugefügt

129
app.py
View File

@@ -1,4 +1,4 @@
from flask import Flask, render_template, request, redirect, url_for, session, abort, jsonify, g from flask import Flask, render_template, request, redirect, url_for, session, abort, jsonify, g, make_response
from flask_babel import Babel, gettext, ngettext, get_locale from flask_babel import Babel, gettext, ngettext, get_locale
from datetime import datetime, timedelta from datetime import datetime, timedelta
import numpy as np import numpy as np
@@ -20,7 +20,23 @@ app.config['BABEL_TRANSLATION_DIRECTORIES'] = 'translations'
babel = Babel() babel = Babel()
# Version der App # Version der App
APP_VERSION = "1.4.2" APP_VERSION = "1.4.12"
def add_cache_headers(response):
"""Fügt Cache-Control-Header hinzu, die den Back-Forward-Cache ermöglichen"""
# Cache-Control für statische Inhalte und API-Endpunkte
if request.path.startswith('/static/') or request.path.startswith('/api/'):
response.headers['Cache-Control'] = 'public, max-age=3600, s-maxage=3600'
else:
# Für HTML-Seiten: kurze Cache-Zeit, aber Back-Forward-Cache erlauben
response.headers['Cache-Control'] = 'public, max-age=60, s-maxage=300'
# Wichtig: Keine Vary-Header für User-Agent oder andere dynamische Werte
# Dies verhindert den Back-Forward-Cache
if 'Vary' in response.headers:
del response.headers['Vary']
return response
# HTML-Template wird jetzt aus templates/index.html geladen # HTML-Template wird jetzt aus templates/index.html geladen
@@ -229,34 +245,72 @@ def index():
plusminus_result = f"Datum {d.strftime('%d.%m.%Y')} {'plus' if anzahl_int>=0 else 'minus'} {abs(anzahl_int)} Monate: {result.strftime('%d.%m.%Y')}" plusminus_result = f"Datum {d.strftime('%d.%m.%Y')} {'plus' if anzahl_int>=0 else 'minus'} {abs(anzahl_int)} Monate: {result.strftime('%d.%m.%Y')}"
except Exception: except Exception:
plusminus_result = gettext('Ungültige Eingabe') plusminus_result = gettext('Ungültige Eingabe')
return render_template('index.html', tage=tage, werktage=werktage, wochentag=wochentag, plusminus_result=plusminus_result, kw_berechnen=kw_berechnen, kw_datum=kw_datum, active_idx=active_idx response = make_response(render_template('index.html', tage=tage, werktage=werktage, wochentag=wochentag, plusminus_result=plusminus_result, kw_berechnen=kw_berechnen, kw_datum=kw_datum, active_idx=active_idx
, feiertage_anzahl=feiertage_anzahl, wochenendtage_anzahl=wochenendtage_anzahl, app_version=APP_VERSION, get_locale=get_locale , feiertage_anzahl=feiertage_anzahl, wochenendtage_anzahl=wochenendtage_anzahl, app_version=APP_VERSION, get_locale=get_locale
) ))
return add_cache_headers(response)
def parse_log_stats(log_path): def parse_log_stats(log_path):
pageviews = 0 pageviews = 0
func_counts = {} func_counts = {}
func_counts_hourly = {}
impressions_per_day = {} impressions_per_day = {}
impressions_per_hour = {}
api_counts = {} api_counts = {}
api_counts_hourly = {}
if os.path.exists(log_path): if os.path.exists(log_path):
with open(log_path, encoding='utf-8') as f: with open(log_path, encoding='utf-8') as f:
for line in f: for line in f:
if 'PAGEVIEW' in line: if 'PAGEVIEW' in line:
pageviews += 1 pageviews += 1
try: try:
date = line[:10] # Parse timestamp (format: YYYY-MM-DDTHH:MM:SS)
timestamp = line[:19] # First 19 chars for YYYY-MM-DDTHH:MM:SS
date = timestamp[:10] # YYYY-MM-DD
hour = timestamp[11:13] # HH
if len(date) == 10 and date[4] == '-' and date[7] == '-': if len(date) == 10 and date[4] == '-' and date[7] == '-':
impressions_per_day[date] = impressions_per_day.get(date, 0) + 1 impressions_per_day[date] = impressions_per_day.get(date, 0) + 1
if len(hour) == 2 and hour.isdigit():
hour_key = f"{date} {hour}:00"
impressions_per_hour[hour_key] = impressions_per_hour.get(hour_key, 0) + 1
except Exception: except Exception:
pass pass
elif 'FUNC:' in line: elif 'FUNC:' in line:
func = line.split('FUNC:')[1].strip() func = line.split('FUNC:')[1].strip()
func_counts[func] = func_counts.get(func, 0) + 1 func_counts[func] = func_counts.get(func, 0) + 1
# Stündliche Funktionsaufrufe
try:
timestamp = line[:19]
date = timestamp[:10]
hour = timestamp[11:13]
if len(hour) == 2 and hour.isdigit():
hour_key = f"{date} {hour}:00"
if hour_key not in func_counts_hourly:
func_counts_hourly[hour_key] = {}
func_counts_hourly[hour_key][func] = func_counts_hourly[hour_key].get(func, 0) + 1
except Exception:
pass
elif 'FUNC_API:' in line: elif 'FUNC_API:' in line:
api = line.split('FUNC_API:')[1].strip() api = line.split('FUNC_API:')[1].strip()
api_counts[api] = api_counts.get(api, 0) + 1 api_counts[api] = api_counts.get(api, 0) + 1
return pageviews, func_counts, impressions_per_day, api_counts
# Stündliche API-Aufrufe
try:
timestamp = line[:19]
date = timestamp[:10]
hour = timestamp[11:13]
if len(hour) == 2 and hour.isdigit():
hour_key = f"{date} {hour}:00"
if hour_key not in api_counts_hourly:
api_counts_hourly[hour_key] = {}
api_counts_hourly[hour_key][api] = api_counts_hourly[hour_key].get(api, 0) + 1
except Exception:
pass
return pageviews, func_counts, func_counts_hourly, impressions_per_day, impressions_per_hour, api_counts, api_counts_hourly
@app.route('/stats', methods=['GET', 'POST']) @app.route('/stats', methods=['GET', 'POST'])
def stats(): def stats():
@@ -267,11 +321,14 @@ def stats():
session['stats_auth'] = True session['stats_auth'] = True
return redirect(url_for('stats')) return redirect(url_for('stats'))
else: else:
return render_template('stats_login.html', error='Falsches Passwort!') response = make_response(render_template('stats_login.html', error='Falsches Passwort!'))
return render_template('stats_login.html', error=None) return add_cache_headers(response)
response = make_response(render_template('stats_login.html', error=None))
return add_cache_headers(response)
log_path = os.path.join('log', 'pageviews.log') log_path = os.path.join('log', 'pageviews.log')
pageviews, func_counts, impressions_per_day, api_counts = parse_log_stats(log_path) pageviews, func_counts, func_counts_hourly, impressions_per_day, impressions_per_hour, api_counts, api_counts_hourly = parse_log_stats(log_path)
return render_template('stats_dashboard.html', pageviews=pageviews, func_counts=func_counts, impressions_per_day=impressions_per_day, api_counts=api_counts) response = make_response(render_template('stats_dashboard.html', pageviews=pageviews, func_counts=func_counts, func_counts_hourly=func_counts_hourly, impressions_per_day=impressions_per_day, impressions_per_hour=impressions_per_hour, api_counts=api_counts, api_counts_hourly=api_counts_hourly))
return add_cache_headers(response)
# --- REST API --- # --- REST API ---
def log_api_usage(api_name): def log_api_usage(api_name):
@@ -305,7 +362,8 @@ def api_tage_werktage():
tage = int(np.busday_count(d1.date(), (d2 + timedelta(days=1)).date(), holidays=holidays)) tage = int(np.busday_count(d1.date(), (d2 + timedelta(days=1)).date(), holidays=holidays))
else: else:
tage = abs((d2 - d1).days) tage = abs((d2 - d1).days)
return jsonify({'result': tage}) response = jsonify({'result': tage})
return add_cache_headers(response)
except Exception as e: except Exception as e:
return jsonify({'error': 'Ungültige Eingabe', 'details': str(e)}), 400 return jsonify({'error': 'Ungültige Eingabe', 'details': str(e)}), 400
@@ -318,7 +376,8 @@ def api_wochentag():
d = datetime.strptime(datum, '%Y-%m-%d') d = datetime.strptime(datum, '%Y-%m-%d')
wochentage = get_wochentage() wochentage = get_wochentage()
wochentag = wochentage[d.weekday()] wochentag = wochentage[d.weekday()]
return jsonify({'result': wochentag}) response = jsonify({'result': wochentag})
return add_cache_headers(response)
except Exception as e: except Exception as e:
return jsonify({'error': 'Ungültige Eingabe', 'details': str(e)}), 400 return jsonify({'error': 'Ungültige Eingabe', 'details': str(e)}), 400
@@ -335,7 +394,8 @@ def api_kw_berechnen():
kw_berechnen = f"Week {kw} ({d.year})" kw_berechnen = f"Week {kw} ({d.year})"
else: else:
kw_berechnen = f"KW {kw} ({d.year})" kw_berechnen = f"KW {kw} ({d.year})"
return jsonify({'result': kw_berechnen, 'kw': kw, 'jahr': d.year}) response = jsonify({'result': kw_berechnen, 'kw': kw, 'jahr': d.year})
return add_cache_headers(response)
except Exception as e: except Exception as e:
return jsonify({'error': 'Ungültige Eingabe', 'details': str(e)}), 400 return jsonify({'error': 'Ungültige Eingabe', 'details': str(e)}), 400
@@ -355,7 +415,8 @@ def api_kw_datum():
kw_datum = f"{start.strftime('%m/%d/%Y')} to {end.strftime('%m/%d/%Y')}" kw_datum = f"{start.strftime('%m/%d/%Y')} to {end.strftime('%m/%d/%Y')}"
else: else:
kw_datum = f"{start.strftime('%d.%m.%Y')} bis {end.strftime('%d.%m.%Y')}" kw_datum = f"{start.strftime('%d.%m.%Y')} bis {end.strftime('%d.%m.%Y')}"
return jsonify({'result': kw_datum, 'start': start.strftime('%Y-%m-%d'), 'end': end.strftime('%Y-%m-%d')}) response = jsonify({'result': kw_datum, 'start': start.strftime('%Y-%m-%d'), 'end': end.strftime('%Y-%m-%d')})
return add_cache_headers(response)
except Exception as e: except Exception as e:
return jsonify({'error': 'Ungültige Eingabe', 'details': str(e)}), 400 return jsonify({'error': 'Ungültige Eingabe', 'details': str(e)}), 400
@@ -378,34 +439,26 @@ def api_plusminus():
if is_werktage: if is_werktage:
result = np.busday_offset(d.date(), anzahl_int, roll='forward') result = np.busday_offset(d.date(), anzahl_int, roll='forward')
result_dt = datetime.strptime(str(result), '%Y-%m-%d') result_dt = datetime.strptime(str(result), '%Y-%m-%d')
if locale == 'en': response = jsonify({'result': result_dt.strftime('%Y-%m-%d')})
plusminus_result = f"Date {d.strftime('%m/%d/%Y')} {'plus' if anzahl_int>=0 else 'minus'} {abs(anzahl_int)} workdays: {result_dt.strftime('%m/%d/%Y')}" return add_cache_headers(response)
else:
plusminus_result = f"Datum {d.strftime('%d.%m.%Y')} {'plus' if anzahl_int>=0 else 'minus'} {abs(anzahl_int)} Werktage: {result_dt.strftime('%d.%m.%Y')}"
else: else:
result = d + timedelta(days=anzahl_int) result = d + timedelta(days=anzahl_int)
if locale == 'en': response = jsonify({'result': result.strftime('%Y-%m-%d')})
plusminus_result = f"Date {d.strftime('%m/%d/%Y')} {'plus' if anzahl_int>=0 else 'minus'} {abs(anzahl_int)} days: {result.strftime('%m/%d/%Y')}" return add_cache_headers(response)
else:
plusminus_result = f"Datum {d.strftime('%d.%m.%Y')} {'plus' if anzahl_int>=0 else 'minus'} {abs(anzahl_int)} Tage: {result.strftime('%d.%m.%Y')}"
elif einheit == 'wochen': elif einheit == 'wochen':
if is_werktage: if is_werktage:
return jsonify({'error': 'Nicht unterstützt: Werktage + Wochen.'}), 400 return jsonify({'error': 'Nicht unterstützt: Werktage + Wochen.'}), 400
else: else:
result = d + timedelta(weeks=anzahl_int) result = d + timedelta(weeks=anzahl_int)
if locale == 'en': response = jsonify({'result': result.strftime('%Y-%m-%d')})
plusminus_result = f"Date {d.strftime('%m/%d/%Y')} {'plus' if anzahl_int>=0 else 'minus'} {abs(anzahl_int)} weeks: {result.strftime('%m/%d/%Y')}" return add_cache_headers(response)
else:
plusminus_result = f"Datum {d.strftime('%d.%m.%Y')} {'plus' if anzahl_int>=0 else 'minus'} {abs(anzahl_int)} Wochen: {result.strftime('%d.%m.%Y')}"
elif einheit == 'monate': elif einheit == 'monate':
if is_werktage: if is_werktage:
return jsonify({'error': 'Nicht unterstützt: Werktage + Monate.'}), 400 return jsonify({'error': 'Nicht unterstützt: Werktage + Monate.'}), 400
else: else:
result = d + relativedelta(months=anzahl_int) result = d + relativedelta(months=anzahl_int)
if locale == 'en': response = jsonify({'result': result.strftime('%Y-%m-%d')})
plusminus_result = f"Date {d.strftime('%m/%d/%Y')} {'plus' if anzahl_int>=0 else 'minus'} {abs(anzahl_int)} months: {result.strftime('%m/%d/%Y')}" return add_cache_headers(response)
else:
plusminus_result = f"Datum {d.strftime('%d.%m.%Y')} {'plus' if anzahl_int>=0 else 'minus'} {abs(anzahl_int)} Monate: {result.strftime('%d.%m.%Y')}"
else: else:
return jsonify({'error': 'Ungültige Einheit'}), 400 return jsonify({'error': 'Ungültige Einheit'}), 400
except Exception as e: except Exception as e:
@@ -414,8 +467,14 @@ def api_plusminus():
@app.route('/api/stats', methods=['GET']) @app.route('/api/stats', methods=['GET'])
def api_stats(): def api_stats():
log_path = os.path.join('log', 'pageviews.log') log_path = os.path.join('log', 'pageviews.log')
pageviews, func_counts, impressions_per_day, api_counts = parse_log_stats(log_path) pageviews, func_counts, func_counts_hourly, impressions_per_day, impressions_per_hour, api_counts, api_counts_hourly = parse_log_stats(log_path)
return render_template('stats_dashboard.html', pageviews=pageviews, func_counts=func_counts, impressions_per_day=impressions_per_day, api_counts=api_counts) response = jsonify({
"pageviews": pageviews,
"func_counts": func_counts,
"impressions_per_day": impressions_per_day,
"api_counts": api_counts
})
return add_cache_headers(response)
@app.route('/api/monitor', methods=['GET']) @app.route('/api/monitor', methods=['GET'])
def api_monitor(): def api_monitor():
@@ -427,17 +486,19 @@ def api_monitor():
if 'PAGEVIEW' in line: if 'PAGEVIEW' in line:
pageviews += 1 pageviews += 1
uptime = int(time.time() - app_start_time) uptime = int(time.time() - app_start_time)
return jsonify({ response = jsonify({
"status": "ok", "status": "ok",
"message": "App running", "message": "App running",
"time": datetime.now().isoformat(), "time": datetime.now().isoformat(),
"uptime_seconds": uptime, "uptime_seconds": uptime,
"pageviews_last_7_days": pageviews "pageviews_last_7_days": pageviews
}) })
return add_cache_headers(response)
@app.route('/api-docs') @app.route('/api-docs')
def api_docs(): def api_docs():
return render_template('swagger.html') response = make_response(render_template('swagger.html'))
return add_cache_headers(response)
if __name__ == '__main__': if __name__ == '__main__':

File diff suppressed because one or more lines are too long

View File

@@ -1,15 +1,25 @@
const CACHE_NAME = 'datumsrechner-cache-v1'; const CACHE_NAME = 'datumsrechner-cache-v1';
const urlsToCache = [ const urlsToCache = [
'/', '/',
'/static/style.css',
'/static/favicon.ico', '/static/favicon.ico',
'/static/favicon.png', '/static/favicon.png',
'/static/favicon.svg',
'/static/logo.svg', '/static/logo.svg',
'/static/manifest.json',
]; ];
self.addEventListener('install', event => { self.addEventListener('install', event => {
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME) caches.open(CACHE_NAME)
.then(cache => cache.addAll(urlsToCache)) .then(cache => {
// Füge nur existierende Dateien zum Cache hinzu
return Promise.allSettled(
urlsToCache.map(url =>
cache.add(url).catch(err => {
console.log('Failed to cache:', url, err);
})
)
);
})
); );
}); });
self.addEventListener('fetch', event => { self.addEventListener('fetch', event => {

View File

@@ -1,4 +1,4 @@
.v{ {
"openapi": "3.0.3", "openapi": "3.0.3",
"info": { "info": {
"title": "Elpatrons Datumsrechner API", "title": "Elpatrons Datumsrechner API",

View File

@@ -6,12 +6,12 @@
{%- endif -%} {%- endif -%}
{% endmacro %} {% endmacro %}
<!doctype html> <!doctype html>
<html lang="de"> <html lang="{{ get_locale() }}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>{{ _('Elpatrons Datumsrechner Open Source Kalender- und Datumsberechnungen') }}</title> <title>{{ _('Elpatrons Datumsrechner Open Source Kalender- und Datumsberechnungen') }}</title>
<meta name="description" content="{{ _('Elpatrons Datumsrechner: Open Source Web-App für Kalender- und Datumsberechnungen. Tage, Werktage, Wochen, Monate, Kalenderwochen, Wochentage und mehr berechnen barrierefrei, werbefrei, trackingfrei, kostenlos.') }}"> <meta name="description" content="{{ _('Elpatrons Datumsrechner: Open Source Web-App für Kalender- und Datumsberechnungen. Tage, Werktage, Wochen, Monate, Kalenderwochen, Wochentage und mehr berechnen barrierefrei, werbefrei, trackingfrei, kostenlos.') }}">
<meta name="keywords" content="{{ _('Datum, Kalender, Datumsrechner, Werktage, Tage zählen, Kalenderwoche, Wochentag, Open Source, Python, Flask, barrierefreiheit, barrierefrei, kostenlos, werbefrei, trackingfrei, cookiefrei') }}"> <meta name="keywords" content="{{ _('Datum, Kalender, Datumsrechner, Werktage, Tage zählen, Kalenderwoche, Wochentag, Open Source, barrierefreiheit, barrierefrei, kostenlos, werbefrei, trackingfrei, cookiefrei, progressive web app, pwa') }}">
<meta property="og:title" content="{{ _('Elpatrons Datumsrechner Open Source Kalender- und Datumsberechnungen') }}"> <meta property="og:title" content="{{ _('Elpatrons Datumsrechner Open Source Kalender- und Datumsberechnungen') }}">
<meta property="og:description" content="{{ _('Open Source Web-App für Kalender- und Datumsberechnungen. Werbefrei, trackingfrei, kostenlos.') }}"> <meta property="og:description" content="{{ _('Open Source Web-App für Kalender- und Datumsberechnungen. Werbefrei, trackingfrei, kostenlos.') }}">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
@@ -72,7 +72,7 @@ body {
} }
.help-button-container { .help-button-container {
position: absolute; position: absolute;
top: 1.5em; top: 1em;
right: 2em; right: 2em;
z-index: 10; z-index: 10;
} }
@@ -97,6 +97,184 @@ body {
background: rgba(37, 99, 235, 0.25); background: rgba(37, 99, 235, 0.25);
border-color: var(--primary); border-color: var(--primary);
} }
.help-button:focus {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
/* Calculator Styles */
.calculator-btn {
background: var(--primary);
color: white;
border: none;
padding: 0.8em 1.5em;
border-radius: 8px;
font-size: 1em;
cursor: pointer;
transition: background-color 0.2s;
font-weight: 500;
width: 100%;
max-width: 480px;
min-height: 44px;
min-width: 44px;
}
.calculator-btn:hover {
background: var(--primary-dark);
}
.calculator-btn:focus {
outline: 3px solid #facc15;
outline-offset: 2px;
box-shadow: 0 0 0 4px #1e293b;
}
.calculator-modal {
max-width: 400px;
width: fit-content;
}
.calculator {
background: #f8fafc;
border-radius: 12px;
padding: 0.8em;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
width: fit-content;
margin: 0 auto;
}
.calculator-display {
margin-bottom: 0.8em;
}
.calculator-history {
margin-bottom: 0.8em;
min-height: 40px;
border: 2px solid #d1d5db;
border-radius: 6px;
background: #f9fafb;
padding: 0.3em;
font-family: 'Courier New', monospace;
font-size: 0.8em;
color: #6b7280;
overflow-y: auto;
max-height: 80px;
}
.history-item {
padding: 0.3em 0;
border-bottom: 1px solid #e5e7eb;
text-align: right;
font-weight: 500;
}
.history-item:last-child {
border-bottom: none;
color: #374151;
font-weight: 600;
}
.calculator-display input {
width: 100%;
padding: 0.8em;
padding-right: 3.5em;
font-size: 1.3em;
text-align: right;
border: 3px solid #374151;
border-radius: 8px;
background: #ffffff;
color: #111827;
font-family: 'Courier New', monospace;
font-weight: 600;
min-height: 48px;
}
.calculator-display input:focus {
outline: 3px solid #facc15;
outline-offset: 2px;
box-shadow: 0 0 0 4px #1e293b;
border-color: #1f2937;
}
.calculator-buttons {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.3em;
margin: 0 auto;
}
.calc-btn {
padding: 0.7em;
font-size: 1em;
border: 2px solid #374151;
background: #f9fafb;
color: #111827;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
font-weight: 600;
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
}
.calc-btn:hover {
background: #e5e7eb;
border-color: #1f2937;
}
.calc-btn:focus {
outline: 3px solid #facc15;
outline-offset: 2px;
box-shadow: 0 0 0 4px #1e293b;
background: #e5e7eb;
border-color: #1f2937;
}
.calc-clear {
background: #ef4444;
color: white;
}
.calc-clear:hover {
background: #dc2626;
}
.calc-delete {
background: #f59e0b;
color: white;
}
.calc-delete:hover {
background: #d97706;
}
.calc-operator {
background: var(--primary);
color: white;
}
.calc-operator:hover {
background: var(--primary-dark);
}
.calc-equals {
background: #10b981;
color: white;
grid-row: span 2;
}
.calc-equals:hover {
background: #059669;
}
.calc-zero {
grid-column: span 2;
}
.help-button:focus { .help-button:focus {
outline: 3px solid #facc15; outline: 3px solid #facc15;
outline-offset: 2px; outline-offset: 2px;
@@ -154,7 +332,7 @@ body {
padding: 2em; padding: 2em;
max-width: 90%; max-width: 90%;
width: 90%; width: 90%;
max-height: 90vh; max-height: 95vh;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
position: relative; position: relative;
@@ -165,17 +343,17 @@ body {
box-sizing: border-box; box-sizing: border-box;
} }
.modal-close { .modal-close {
position: absolute; position: fixed;
top: 1em; top: 1em;
right: 1em; right: 1em;
background: none; background: rgba(255, 255, 255, 0.95);
border: none; border: 2px solid var(--border);
font-size: 1.5em; font-size: 1.5em;
cursor: pointer; cursor: pointer;
color: var(--text); color: var(--text);
padding: 0.5em; padding: 0.5em;
border-radius: 50%; border-radius: 50%;
transition: background 0.2s; transition: all 0.2s;
width: 2.5em; width: 2.5em;
height: 2.5em; height: 2.5em;
display: flex; display: flex;
@@ -183,6 +361,8 @@ body {
justify-content: center; justify-content: center;
min-width: 44px; min-width: 44px;
min-height: 44px; min-height: 44px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1001;
} }
.modal-close:hover { .modal-close:hover {
background: var(--border); background: var(--border);
@@ -473,6 +653,9 @@ button:focus, .accordion-header:focus {
max-width: none; max-width: none;
overflow: hidden; overflow: hidden;
} }
.header-section {
margin-top: 4.5em; /* Mehr Abstand für Sprachauswahl und Hilfe-Button */
}
h1 { h1 {
font-size: 1.3em; font-size: 1.3em;
} }
@@ -503,7 +686,7 @@ button:focus, .accordion-header:focus {
padding: 0.4em 0.6em; padding: 0.4em 0.6em;
} }
.modal-content { .modal-content {
padding: 1.5em; padding: 1.2em;
margin: 1em; margin: 1em;
width: calc(100% - 2em); width: calc(100% - 2em);
max-width: none; max-width: none;
@@ -513,12 +696,61 @@ button:focus, .accordion-header:focus {
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
} }
.calculator-modal {
max-width: 95%;
width: 95%;
}
.calculator {
width: 100%;
padding: 0.8em;
}
.calculator-buttons {
width: 100%;
gap: 0.3em;
}
.calc-btn {
padding: 0.6em;
font-size: 1em;
min-width: 44px;
min-height: 44px;
}
.calculator-display input {
padding: 0.6em;
padding-right: 3em;
font-size: 1.2em;
min-height: 44px;
}
.modal-close {
top: 0.8em;
right: 0.8em;
font-size: 1.3em;
width: 2.2em;
height: 2.2em;
min-width: 48px;
min-height: 48px;
background: rgba(255, 255, 255, 0.98);
border: 2px solid var(--border);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.modal-content h1 {
margin-top: 0;
margin-bottom: 1em;
font-size: 1.2em;
line-height: 1.3;
}
} }
/* Sprachauswahl */ /* Sprachauswahl */
.language-selector { .language-selector {
position: absolute; position: absolute;
top: 1.5em; top: 1em;
left: 2em; left: 2em;
z-index: 10; z-index: 10;
} }
@@ -618,7 +850,11 @@ footer br + a {
function changeLanguage(language) { function changeLanguage(language) {
// Speichere Sprache in localStorage (datenschutzfreundlich) // Speichere Sprache in localStorage (datenschutzfreundlich)
localStorage.setItem('preferred_language', language); localStorage.setItem('preferred_language', language);
window.location.href = '/set_language/' + language;
// Erstelle neue URL mit korrektem lang-Parameter
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.set('lang', language);
window.location.href = currentUrl.toString();
} }
function openAccordion(idx) { function openAccordion(idx) {
const headers = document.querySelectorAll('.accordion-header'); const headers = document.querySelectorAll('.accordion-header');
@@ -648,13 +884,222 @@ footer br + a {
document.querySelector('.help-button').focus(); document.querySelector('.help-button').focus();
} }
// Taschenrechner-Funktionen
let calculatorDisplay = '0';
let calculatorPreviousValue = null;
let calculatorCurrentOperator = null;
let calculatorWaitingForOperand = false;
let calculatorHistory = [];
function showCalculator() {
const modal = document.getElementById('calculatorModal');
const input = document.getElementById('calculatorInput');
if (modal && input) {
modal.style.display = 'flex';
modal.classList.add('active');
input.focus();
}
}
function hideCalculator() {
const modal = document.getElementById('calculatorModal');
modal.style.display = 'none';
modal.classList.remove('active');
}
function calculatorClear() {
calculatorDisplay = '0';
calculatorPreviousValue = null;
calculatorCurrentOperator = null;
calculatorWaitingForOperand = false;
calculatorHistory = [];
updateCalculatorDisplay();
}
function calculatorDelete() {
if (calculatorDisplay.length === 1) {
calculatorDisplay = '0';
} else {
calculatorDisplay = calculatorDisplay.slice(0, -1);
}
updateCalculatorDisplay();
}
function calculatorNumber(num) {
if (calculatorWaitingForOperand) {
calculatorDisplay = num;
calculatorWaitingForOperand = false;
} else {
calculatorDisplay = calculatorDisplay === '0' ? num : calculatorDisplay + num;
}
updateCalculatorDisplay();
}
function calculatorDecimal() {
if (calculatorWaitingForOperand) {
calculatorDisplay = '0.';
calculatorWaitingForOperand = false;
} else if (calculatorDisplay.indexOf('.') === -1) {
calculatorDisplay += '.';
}
updateCalculatorDisplay();
}
function calculatorOperator(op) {
const inputValue = parseFloat(calculatorDisplay);
if (calculatorPreviousValue === null) {
calculatorPreviousValue = inputValue;
} else if (calculatorCurrentOperator) {
const result = performCalculation(calculatorPreviousValue, inputValue, calculatorCurrentOperator);
calculatorDisplay = String(result);
calculatorPreviousValue = result;
}
calculatorWaitingForOperand = true;
calculatorCurrentOperator = op;
updateCalculatorDisplay();
}
function calculatorEquals() {
const inputValue = parseFloat(calculatorDisplay);
if (calculatorPreviousValue === null || calculatorCurrentOperator === null) {
return;
}
const result = performCalculation(calculatorPreviousValue, inputValue, calculatorCurrentOperator);
// Füge zur History hinzu
const historyEntry = calculatorPreviousValue + ' ' + getOperatorSymbol(calculatorCurrentOperator) + ' ' + inputValue + ' = ' + result;
addToHistory(historyEntry);
calculatorDisplay = String(result);
calculatorPreviousValue = null;
calculatorCurrentOperator = null;
calculatorWaitingForOperand = true;
updateCalculatorDisplay();
}
function performCalculation(firstValue, secondValue, operator) {
switch (operator) {
case '+':
return firstValue + secondValue;
case '-':
return firstValue - secondValue;
case '*':
return firstValue * secondValue;
case '/':
return secondValue !== 0 ? firstValue / secondValue : 'Error';
default:
return secondValue;
}
}
function updateCalculatorDisplay() {
const display = document.getElementById('calculatorInput');
if (calculatorDisplay === 'Error') {
display.value = 'Error';
display.setAttribute('aria-label', '{{ _("Fehler") }}');
calculatorDisplay = '0';
calculatorPreviousValue = null;
calculatorCurrentOperator = null;
calculatorWaitingForOperand = false;
} else {
display.value = calculatorDisplay;
display.setAttribute('aria-label', '{{ _("Taschenrechner Anzeige") }}: ' + calculatorDisplay);
}
updateHistoryDisplay();
}
function readAloudFromCalculator(button) {
// Prüfe, ob bereits eine Wiedergabe läuft
if (currentSpeech && speechSynthesis.speaking) {
// Stoppe die aktuelle Wiedergabe
speechSynthesis.cancel();
currentSpeech = null;
button.classList.remove('playing');
button.textContent = '🔊';
return;
}
// Finde das Eingabefeld
const inputElement = document.getElementById('calculatorInput');
if (!inputElement) return;
let textToRead = inputElement.value;
// Übersetze Zahlen und Operatoren für bessere Sprachausgabe
if (textToRead !== 'Error') {
// Prüfe, ob es sich um ein Ergebnis einer Berechnung handelt
if (calculatorHistory.length > 0) {
// Verwende die letzte Berechnung aus der History
const lastCalculation = calculatorHistory[0];
textToRead = lastCalculation;
// Übersetze mathematische Symbole für bessere Sprachausgabe
textToRead = textToRead.replace(/×/g, ' mal ');
textToRead = textToRead.replace(/÷/g, ' geteilt durch ');
textToRead = textToRead.replace(//g, ' minus ');
textToRead = textToRead.replace(/\+/g, ' plus ');
textToRead = textToRead.replace(/\./g, ' Komma ');
textToRead = textToRead.replace(/=/g, ' ist gleich ');
} else {
// Fallback für einzelne Zahlen
textToRead = textToRead.replace(/\./g, ' Komma ');
if (textToRead === '0') {
textToRead = '{{ _("Null") }}';
} else {
textToRead = '{{ _("Taschenrechner Anzeige") }}: ' + textToRead;
}
}
} else {
textToRead = '{{ _("Fehler") }}';
}
readAloud(textToRead, button);
}
function addToHistory(entry) {
calculatorHistory.unshift(entry);
if (calculatorHistory.length > 3) {
calculatorHistory.pop();
}
}
function updateHistoryDisplay() {
const historyContainer = document.getElementById('calculatorHistory');
if (historyContainer) {
historyContainer.innerHTML = '';
calculatorHistory.forEach((entry, index) => {
const historyItem = document.createElement('div');
historyItem.className = 'history-item';
historyItem.textContent = entry;
historyItem.setAttribute('aria-label', '{{ _("Berechnung") }} ' + (index + 1) + ': ' + entry);
historyContainer.appendChild(historyItem);
});
}
}
function getOperatorSymbol(operator) {
switch(operator) {
case '+': return '+';
case '-': return '';
case '*': return '×';
case '/': return '÷';
default: return operator;
}
}
// Sprachausgabe-Funktionalität // Sprachausgabe-Funktionalität
let currentSpeech = null; let currentSpeech = null;
function readAloud(text, button) { function readAloud(text, button) {
// Stoppe vorherige Wiedergabe // Stoppe vorherige Wiedergabe
if (currentSpeech) { if (currentSpeech) {
currentSpeech.cancel(); speechSynthesis.cancel();
currentSpeech = null;
} }
// Entferne "playing" Klasse von allen Buttons // Entferne "playing" Klasse von allen Buttons
@@ -663,12 +1108,92 @@ footer br + a {
btn.textContent = '🔊'; btn.textContent = '🔊';
}); });
// Bestimme die aktuelle Sprache
let currentLang = 'de-DE'; // Standard
// Methode 1: Prüfe URL-Parameter
const urlParams = new URLSearchParams(window.location.search);
const langParam = urlParams.get('lang');
// Methode 2: Prüfe localStorage
const savedLang = localStorage.getItem('preferred_language');
// Methode 3: Prüfe das HTML lang-Attribut
const htmlLang = document.documentElement.lang;
// Debug-Ausgabe
console.log('URL lang param:', langParam);
console.log('Saved lang:', savedLang);
console.log('HTML lang:', htmlLang);
// Verbesserte Spracherkennung - prüfe alle Quellen
if (langParam === 'en' || savedLang === 'en' || htmlLang === 'en') {
// Prüfe, ob eine britische Stimme verfügbar ist
const voices = speechSynthesis.getVoices();
const hasBritishVoice = voices.some(voice => voice.lang === 'en-GB');
const hasAmericanVoice = voices.some(voice => voice.lang === 'en-US');
if (hasBritishVoice) {
currentLang = 'en-GB';
console.log('Setting language to British English:', currentLang);
} else if (hasAmericanVoice) {
currentLang = 'en-US';
console.log('Setting language to American English:', currentLang);
} else {
// Fallback auf en-US, auch wenn keine Stimme verfügbar ist
currentLang = 'en-US';
console.log('Setting language to English (no specific voice available):', currentLang);
}
} else {
console.log('Setting language to German:', currentLang);
}
// Erstelle neue Sprachausgabe // Erstelle neue Sprachausgabe
currentSpeech = new SpeechSynthesisUtterance(text); currentSpeech = new SpeechSynthesisUtterance(text);
currentSpeech.lang = 'de-DE'; currentSpeech.lang = currentLang;
currentSpeech.rate = 0.9; currentSpeech.rate = 0.9;
currentSpeech.pitch = 1; currentSpeech.pitch = 1;
// Versuche eine passende Stimme zu finden
const voices = speechSynthesis.getVoices();
console.log('Available voices:', voices.map(v => `${v.name} (${v.lang})`));
// Suche nach einer Stimme in der gewünschten Sprache
let preferredVoice = voices.find(voice =>
voice.lang === currentLang
);
// Falls keine exakte Übereinstimmung, suche nach ähnlicher Sprache
if (!preferredVoice) {
preferredVoice = voices.find(voice =>
voice.lang.startsWith(currentLang.split('-')[0])
);
}
// Falls immer noch keine Stimme gefunden, suche nach englischen Stimmen für Englisch
if (!preferredVoice && (currentLang === 'en-US' || currentLang === 'en-GB')) {
// Bevorzuge britische Stimmen für en-GB
if (currentLang === 'en-GB') {
preferredVoice = voices.find(voice =>
voice.lang === 'en-GB' || voice.name.toLowerCase().includes('british')
);
}
// Falls keine britische Stimme, suche nach amerikanischen oder allgemeinen englischen Stimmen
if (!preferredVoice) {
preferredVoice = voices.find(voice =>
voice.lang.includes('en') || voice.name.toLowerCase().includes('english')
);
}
}
if (preferredVoice) {
currentSpeech.voice = preferredVoice;
console.log('Using voice:', preferredVoice.name, 'for language:', currentLang);
} else {
console.log('No specific voice found for language:', currentLang, '- using default');
}
// Button-Status aktualisieren // Button-Status aktualisieren
button.classList.add('playing'); button.classList.add('playing');
button.textContent = '⏹️'; button.textContent = '⏹️';
@@ -691,6 +1216,16 @@ footer br + a {
} }
function readAloudFromElement(button) { function readAloudFromElement(button) {
// Prüfe, ob bereits eine Wiedergabe läuft
if (currentSpeech && speechSynthesis.speaking) {
// Stoppe die aktuelle Wiedergabe
speechSynthesis.cancel();
currentSpeech = null;
button.classList.remove('playing');
button.textContent = '🔊';
return;
}
// Finde das Ergebnis-Element (das div mit class="result") // Finde das Ergebnis-Element (das div mit class="result")
const resultElement = button.closest('.result'); const resultElement = button.closest('.result');
if (!resultElement) return; if (!resultElement) return;
@@ -707,7 +1242,7 @@ footer br + a {
function stopReading() { function stopReading() {
if (currentSpeech) { if (currentSpeech) {
currentSpeech.cancel(); speechSynthesis.cancel();
currentSpeech = null; currentSpeech = null;
} }
document.querySelectorAll('.read-aloud-btn').forEach(btn => { document.querySelectorAll('.read-aloud-btn').forEach(btn => {
@@ -717,6 +1252,13 @@ footer br + a {
} }
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Stelle sicher, dass die Stimmen geladen sind
if (speechSynthesis.onvoiceschanged !== undefined) {
speechSynthesis.onvoiceschanged = function() {
console.log('Voices loaded:', speechSynthesis.getVoices().length);
};
}
// Prüfe localStorage für gespeicherte Sprachauswahl // Prüfe localStorage für gespeicherte Sprachauswahl
const savedLanguage = localStorage.getItem('preferred_language'); const savedLanguage = localStorage.getItem('preferred_language');
if (savedLanguage && !window.location.search.includes('lang=')) { if (savedLanguage && !window.location.search.includes('lang=')) {
@@ -762,6 +1304,7 @@ footer br + a {
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
hideHelp(); hideHelp();
hideCalculator();
stopReading(); stopReading();
} }
}); });
@@ -772,6 +1315,101 @@ footer br + a {
hideHelp(); hideHelp();
} }
}); });
// Klick außerhalb des Taschenrechner-Modals zum Schließen
document.getElementById('calculatorModal').addEventListener('click', function(e) {
if (e.target === this) {
hideCalculator();
}
});
// Tastatursteuerung für Taschenrechner-Button
document.getElementById('calculatorBtn').addEventListener('keydown', function(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
showCalculator();
}
});
// Tastatursteuerung für Taschenrechner
document.addEventListener('keydown', function(event) {
const modal = document.getElementById('calculatorModal');
if (modal.style.display === 'flex') {
switch(event.key) {
case '0': case '1': case '2': case '3': case '4':
case '5': case '6': case '7': case '8': case '9':
calculatorNumber(event.key);
event.preventDefault();
break;
case '.':
case ',':
calculatorDecimal();
event.preventDefault();
break;
case '+':
case 'p': case 'P': // Plus
calculatorOperator('+');
event.preventDefault();
break;
case '-':
case 'm': case 'M': // Minus
calculatorOperator('-');
event.preventDefault();
break;
case '*':
case 'x': case 'X': // Multiplikation
case '×':
calculatorOperator('*');
event.preventDefault();
break;
case '/':
case 'd': case 'D': // Division
case '÷':
calculatorOperator('/');
event.preventDefault();
break;
case '=':
case 'Enter':
case ' ': // Leertaste für Gleich
calculatorEquals();
event.preventDefault();
break;
case 'Escape':
hideCalculator();
event.preventDefault();
break;
case 'Backspace':
case 'Delete':
calculatorDelete();
event.preventDefault();
break;
case 'c': case 'C':
case 'Escape':
calculatorClear();
event.preventDefault();
break;
case 'Tab':
// Erlaube normale Tab-Navigation
break;
default:
// Verhindere andere Tastatureingaben
event.preventDefault();
break;
}
}
});
// Werktage-Checkbox Event-Handler
var werktageCheckbox = document.getElementById('werktage');
var bundeslandSelect = document.getElementById('bundesland');
if (werktageCheckbox && bundeslandSelect) {
function toggleBundesland() {
bundeslandSelect.disabled = !werktageCheckbox.checked;
}
werktageCheckbox.addEventListener('change', toggleBundesland);
// Initial setzen
toggleBundesland();
}
}); });
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
window.addEventListener('load', function() { window.addEventListener('load', function() {
@@ -792,7 +1430,7 @@ footer br + a {
<option value="en" {% if get_locale() == 'en' %}selected{% endif %}>{{ _('English') }}</option> <option value="en" {% if get_locale() == 'en' %}selected{% endif %}>{{ _('English') }}</option>
</select> </select>
</div> </div>
<div style="text-align:center; margin-bottom:1.2em;"> <div class="header-section" style="text-align:center; margin-bottom:1.2em;">
<div style="font-size:1.1em; font-style:italic; color:#475569;">{{ _('Elpatrons') }}</div> <div style="font-size:1.1em; font-style:italic; color:#475569;">{{ _('Elpatrons') }}</div>
<h1 style="margin:0;">{{ _('Datumsrechner') }}</h1> <h1 style="margin:0;">{{ _('Datumsrechner') }}</h1>
<div style="font-size:0.9em; color:#1e293b; margin-top:0.3em;"> <div style="font-size:0.9em; color:#1e293b; margin-top:0.3em;">
@@ -848,11 +1486,11 @@ footer br + a {
</select> </select>
</label> </label>
</fieldset> </fieldset>
<button name="action" value="tage_werktage" type="submit">Berechnen</button> <button name="action" value="tage_werktage" type="submit">{{ _('Berechnen') }}</button>
</form> </form>
{% if tage is not none %} {% if tage is not none %}
<div class="result" aria-live="polite"> <div class="result" aria-live="polite">
<button type="button" class="read-aloud-btn" onclick="readAloudFromElement(this)" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); readAloudFromElement(this); }" aria-label="Ergebnis vorlesen" title="Ergebnis vorlesen" tabindex="0">🔊</button> <button type="button" class="read-aloud-btn" onclick="readAloudFromElement(this)" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); readAloudFromElement(this); }" aria-label="{{ _('Ergebnis vorlesen') }}" title="{{ _('Ergebnis vorlesen') }}" tabindex="0">🔊</button>
{% if request.form.get('werktage') %} {% if request.form.get('werktage') %}
{{ _('Anzahl der Werktage zwischen') }} <b>{{ format_date(request.form.get('start1', '')) }}</b> {{ _('und') }} <b>{{ format_date(request.form.get('end1', '')) }}:</b>{% if request.form.get('bundesland') %} {{ _('(Feiertage:') }} {{ request.form.get('bundesland') }}){% endif %}: {{ tage }} {{ _('Anzahl der Werktage zwischen') }} <b>{{ format_date(request.form.get('start1', '')) }}</b> {{ _('und') }} <b>{{ format_date(request.form.get('end1', '')) }}:</b>{% if request.form.get('bundesland') %} {{ _('(Feiertage:') }} {{ request.form.get('bundesland') }}){% endif %}: {{ tage }}
{% else %} {% else %}
@@ -894,7 +1532,7 @@ footer br + a {
</form> </form>
{% if wochentag is not none %} {% if wochentag is not none %}
<div class="result" aria-live="polite"> <div class="result" aria-live="polite">
<button type="button" class="read-aloud-btn" onclick="readAloudFromElement(this)" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); readAloudFromElement(this); }" aria-label="Ergebnis vorlesen" title="Ergebnis vorlesen" tabindex="0">🔊</button> <button type="button" class="read-aloud-btn" onclick="readAloudFromElement(this)" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); readAloudFromElement(this); }" aria-label="{{ _('Ergebnis vorlesen') }}" title="{{ _('Ergebnis vorlesen') }}" tabindex="0">🔊</button>
{{ _('Wochentag von') }} <b>{{ format_date(request.form.get('datum3', '')) }}</b>: {{ wochentag }} {{ _('Wochentag von') }} <b>{{ format_date(request.form.get('datum3', '')) }}</b>: {{ wochentag }}
</div> </div>
{% endif %} {% endif %}
@@ -920,7 +1558,7 @@ footer br + a {
</form> </form>
{% if kw_berechnen is not none %} {% if kw_berechnen is not none %}
<div class="result" aria-live="polite"> <div class="result" aria-live="polite">
<button type="button" class="read-aloud-btn" onclick="readAloudFromElement(this)" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); readAloudFromElement(this); }" aria-label="Ergebnis vorlesen" title="Ergebnis vorlesen" tabindex="0">🔊</button> <button type="button" class="read-aloud-btn" onclick="readAloudFromElement(this)" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); readAloudFromElement(this); }" aria-label="{{ _('Ergebnis vorlesen') }}" title="{{ _('Ergebnis vorlesen') }}" tabindex="0">🔊</button>
{{ _('Kalenderwoche von') }} <b>{{ format_date(request.form.get('datum6', '')) }}</b>: {{ kw_berechnen }} {{ _('Kalenderwoche von') }} <b>{{ format_date(request.form.get('datum6', '')) }}</b>: {{ kw_berechnen }}
</div> </div>
{% endif %} {% endif %}
@@ -946,7 +1584,7 @@ footer br + a {
</form> </form>
{% if kw_datum is not none %} {% if kw_datum is not none %}
<div class="result" aria-live="polite"> <div class="result" aria-live="polite">
<button type="button" class="read-aloud-btn" onclick="readAloudFromElement(this)" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); readAloudFromElement(this); }" aria-label="Ergebnis vorlesen" title="Ergebnis vorlesen" tabindex="0">🔊</button> <button type="button" class="read-aloud-btn" onclick="readAloudFromElement(this)" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); readAloudFromElement(this); }" aria-label="{{ _('Ergebnis vorlesen') }}" title="{{ _('Ergebnis vorlesen') }}" tabindex="0">🔊</button>
{{ _('Start-/Enddatum der KW') }} <b>{{ request.form.get('kw7', '') }}</b> {{ _('im Jahr') }} <b>{{ request.form.get('jahr7', '') }}</b>: {{ kw_datum }} {{ _('Start-/Enddatum der KW') }} <b>{{ request.form.get('kw7', '') }}</b> {{ _('im Jahr') }} <b>{{ request.form.get('jahr7', '') }}</b>: {{ kw_datum }}
</div> </div>
{% endif %} {% endif %}
@@ -992,19 +1630,69 @@ footer br + a {
</form> </form>
{% if plusminus_result is not none %} {% if plusminus_result is not none %}
<div class="result" aria-live="polite"> <div class="result" aria-live="polite">
<button type="button" class="read-aloud-btn" onclick="readAloudFromElement(this)" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); readAloudFromElement(this); }" aria-label="Ergebnis vorlesen" title="Ergebnis vorlesen" tabindex="0">🔊</button> <button type="button" class="read-aloud-btn" onclick="readAloudFromElement(this)" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); readAloudFromElement(this); }" aria-label="{{ _('Ergebnis vorlesen') }}" title="{{ _('Ergebnis vorlesen') }}" tabindex="0">🔊</button>
{{ plusminus_result }} {{ plusminus_result }}
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
<!-- Calculator Button -->
<div style="text-align: center; margin-top: 1.5em;">
<button type="button" id="calculatorBtn" class="calculator-btn" onclick="showCalculator()" aria-label="{{ _('Taschenrechner öffnen') }}" title="{{ _('Taschenrechner öffnen') }}" tabindex="0">
🧮 {{ _('Taschenrechner') }}
</button>
</div>
</div>
<!-- Calculator Modal Overlay -->
<div id="calculatorModal" class="modal-overlay" role="dialog" aria-labelledby="calculator-title" style="display: none;">
<div class="modal-content calculator-modal">
<button type="button" class="modal-close" onclick="hideCalculator()" aria-label="{{ _('Taschenrechner schließen') }}">&times;</button>
<h1 id="calculator-title">{{ _('Taschenrechner') }}</h1>
<p class="sr-only">{{ _('Verwenden Sie die Tab-Taste um durch die Tasten zu navigieren. Tastatur-Kurzbefehle: Zahlen 0-9, Punkt oder Komma für Dezimal, Plus (+) oder P für Addition, Minus (-) oder M für Subtraktion, Stern (*) oder X für Multiplikation, Schrägstrich (/) oder D für Division, Enter oder Leertaste für Gleich, C für Löschen, Backspace für letzte Ziffer löschen.') }}</p>
<div class="calculator">
<div class="calculator-history" id="calculatorHistory" aria-label="{{ _('Berechnungsverlauf') }}" role="log" aria-live="polite"></div>
<div class="calculator-display">
<div style="position: relative; display: flex; align-items: center;">
<input type="text" id="calculatorInput" readonly aria-label="{{ _('Taschenrechner Anzeige') }}" value="0" role="textbox" aria-live="polite">
<button type="button" class="read-aloud-btn" onclick="readAloudFromCalculator(this)" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); readAloudFromCalculator(this); }" aria-label="{{ _('Ergebnis vorlesen') }}" title="{{ _('Ergebnis vorlesen') }}" tabindex="0" style="position: absolute; right: 0.5em; top: 50%; transform: translateY(-50%);">🔊</button>
</div>
</div>
<div class="calculator-buttons">
<button type="button" onclick="calculatorClear()" class="calc-btn calc-clear" aria-label="{{ _('Löschen (Taste: C)') }}" tabindex="0">C</button>
<button type="button" onclick="calculatorDelete()" class="calc-btn calc-delete" aria-label="{{ _('Letzte Ziffer löschen (Taste: Backspace)') }}" tabindex="0"></button>
<button type="button" onclick="calculatorOperator('/')" class="calc-btn calc-operator" aria-label="{{ _('Dividieren (Taste: / oder D)') }}" tabindex="0">÷</button>
<button type="button" onclick="calculatorOperator('*')" class="calc-btn calc-operator" aria-label="{{ _('Multiplizieren (Taste: * oder X)') }}" tabindex="0">×</button>
<button type="button" onclick="calculatorNumber('7')" class="calc-btn" aria-label="{{ _('Sieben') }}" tabindex="0">7</button>
<button type="button" onclick="calculatorNumber('8')" class="calc-btn" aria-label="{{ _('Acht') }}" tabindex="0">8</button>
<button type="button" onclick="calculatorNumber('9')" class="calc-btn" aria-label="{{ _('Neun') }}" tabindex="0">9</button>
<button type="button" onclick="calculatorOperator('-')" class="calc-btn calc-operator" aria-label="{{ _('Subtrahieren (Taste: - oder M)') }}" tabindex="0"></button>
<button type="button" onclick="calculatorNumber('4')" class="calc-btn" aria-label="{{ _('Vier') }}" tabindex="0">4</button>
<button type="button" onclick="calculatorNumber('5')" class="calc-btn" aria-label="{{ _('Fünf') }}" tabindex="0">5</button>
<button type="button" onclick="calculatorNumber('6')" class="calc-btn" aria-label="{{ _('Sechs') }}" tabindex="0">6</button>
<button type="button" onclick="calculatorOperator('+')" class="calc-btn calc-operator" aria-label="{{ _('Addieren (Taste: + oder P)') }}" tabindex="0">+</button>
<button type="button" onclick="calculatorNumber('1')" class="calc-btn" aria-label="{{ _('Eins') }}" tabindex="0">1</button>
<button type="button" onclick="calculatorNumber('2')" class="calc-btn" aria-label="{{ _('Zwei') }}" tabindex="0">2</button>
<button type="button" onclick="calculatorNumber('3')" class="calc-btn" aria-label="{{ _('Drei') }}" tabindex="0">3</button>
<button type="button" onclick="calculatorEquals()" class="calc-btn calc-equals" aria-label="{{ _('Gleich (Taste: Enter oder Leertaste)') }}" tabindex="0">=</button>
<button type="button" onclick="calculatorNumber('0')" class="calc-btn calc-zero" aria-label="{{ _('Null') }}" tabindex="0">0</button>
<button type="button" onclick="calculatorDecimal()" class="calc-btn" aria-label="{{ _('Komma (Taste: . oder ,)') }}" tabindex="0">.</button>
</div>
</div>
</div>
</div> </div>
<!-- Help Modal Overlay --> <!-- Help Modal Overlay -->
<div id="helpModal" class="modal-overlay" role="dialog" aria-labelledby="help-title" aria-describedby="help-content"> <div id="helpModal" class="modal-overlay" role="dialog" aria-labelledby="help-title" aria-describedby="help-content">
<div class="modal-content"> <div class="modal-content">
<button type="button" class="modal-close" onclick="hideHelp()" aria-label="Hilfe schließen">&times;</button> <button type="button" class="modal-close" onclick="hideHelp()" aria-label="{{ _('Hilfe schließen') }}">&times;</button>
<h1 id="help-title">{{ _('Was ist Elpatrons Datumsrechner?') }}</h1> <h1 id="help-title">{{ _('Was ist Elpatrons Datumsrechner?') }}</h1>
<p>{{ _('Der Datumsrechner kann verschiedene Datumsberechnungen durchführen:') }}</p> <p>{{ _('Der Datumsrechner kann verschiedene Datumsberechnungen durchführen:') }}</p>
@@ -1020,56 +1708,43 @@ footer br + a {
<li>{{ _('Start-/Enddatum einer Kalenderwoche eines Jahres') }}</li> <li>{{ _('Start-/Enddatum einer Kalenderwoche eines Jahres') }}</li>
</ul> </ul>
<h2>Online Datumsrechner gibt es bereits in einer Vielzahl, warum also noch einer?</h2> <h2>{{ _('Online Datumsrechner gibt es bereits in einer Vielzahl, warum also noch einer?') }}</h2>
<p>Aus zwei Gründen:</p> <p>{{ _('Aus zwei Gründen:') }}</p>
<ul> <ul>
<li>Finde mal einen Datumsrechner, der nicht vollkommen verseucht mit Werbung, Tracking und Cookies ist!</li> <li>{{ _('Finde mal einen Datumsrechner, der nicht vollkommen verseucht mit Werbung, Tracking und Cookies ist!') }}</li>
<li>Das hat mich so geärgert, dass ich meinen eigenen programmiert habe. <li>{{ _('Das hat mich so geärgert, dass ich meinen eigenen programmiert habe.') }}
<ul> <ul>
<li>Genau genommen nicht ich selbst. Diese App wurde zum überwiegenden Teil von KI nach meinen Anweisungen entwickelt (Vibe Coding).</li> <li>{{ _('Genau genommen nicht ich selbst. Diese App wurde zum überwiegenden Teil von KI nach meinen Anweisungen entwickelt (Vibe Coding).') }}</li>
</ul> </ul>
</li> </li>
</ul> </ul>
<h2>Was du noch wissen solltest</h2> <h2>{{ _('Was du noch wissen solltest') }}</h2>
<ul> <ul>
<li>Ich habe versucht, die App möglichst barrierefrei zu gestalten, um Menschen mit Einschränkungen die Benutzung zu erleichtern.</li> <li>{{ _('Ich habe versucht, die App möglichst barrierefrei zu gestalten, um Menschen mit Einschränkungen die Benutzung zu erleichtern.') }}</li>
<li>Diese App schnüffelt dir nicht hinterher, sammelt keine persönlichen Daten und geht dir auch sonst (hoffentlich!) in keiner Weise auf die Nerven.</li> <li>{{ _('Diese App schnüffelt dir nicht hinterher, sammelt keine persönlichen Daten und geht dir auch sonst (hoffentlich!) in keiner Weise auf die Nerven.') }}</li>
<li>Den Quellcode dieser App habe ich auf <a href="https://codeberg.org/elpatron/datecalc" target="_blank">Codeberg</a> veröffentlicht, du kannst ihn einsehen, verändern oder damit deinen eigenen kleinen Datumsrechner betreiben.</li> <li>{{ _('Den Quellcode dieser App habe ich auf') }} <a href="https://codeberg.org/elpatron/datecalc" target="_blank">Codeberg</a> {{ _('veröffentlicht, du kannst ihn einsehen, verändern oder damit deinen eigenen kleinen Datumsrechner betreiben.') }}</li>
<li>Die App läuft auf meinem kleinen Home-Server und ist derzeit nicht für große Besucherzahlen ausgelegt.</li> <li>{{ _('Die App läuft auf meinem kleinen Home-Server und ist derzeit nicht für große Besucherzahlen ausgelegt.') }}</li>
<li>Ich übernehme keine Gewähr für die Funktionalität und die Rechenergebnisse. Die KI, die das programmiert hat, übrigens auch nicht.</li> <li>{{ _('Ich übernehme keine Gewähr für die Funktionalität und die Rechenergebnisse. Die KI, die das programmiert hat, übrigens auch nicht.') }}</li>
<li>Falls du einen Fehler findest oder eine weitere Funktion wünschst, kannst du mir eine Mail schreiben (siehe Mailto Link in der Fußzeile)</li> <li>{{ _('Falls du einen Fehler findest oder eine weitere Funktion wünschst, kannst du mir eine Mail schreiben (siehe Mailto Link in der Fußzeile)') }}</li>
</ul> </ul>
<p><strong>Hab Spaß mit Elpatrons Datumsrechner! Dein M. Busche</strong></p> <p><strong>{{ _('Hab Spaß mit Elpatrons Datumsrechner! Dein M. Busche') }}</strong></p>
</div> </div>
<div id="help-content" class="sr-only"> <div id="help-content" class="sr-only">
Hilfe-Informationen für den Datumsrechner mit Erklärungen zu allen Funktionen {{ _('Hilfe-Informationen für den Datumsrechner mit Erklärungen zu allen Funktionen') }}
</div> </div>
</div> </div>
<footer style="text-align:center; margin-top:2em; color:#475569; font-size:0.98em; padding-bottom:1.5em;"> <footer style="text-align:center; margin-top:2em; color:#475569; font-size:0.98em; padding-bottom:1.5em;">
Dies ist ein werbe- und trackingfreier <a href="https://codeberg.org/elpatron/datecalc/src/branch/main/README.md" target="_blank" style="color:#1e40af; text-decoration:underline;">Open Source Datumsrechner</a><br> Dies ist ein werbe- und trackingfreier <a href="https://codeberg.org/elpatron/datecalc/src/branch/main/README.md" target="_blank" style="color:#1e40af; text-decoration:underline;">Open Source</a> Datumsrechner<br>
<a href="/api-docs" target="_blank" style="color:#1e40af; text-decoration:underline;">REST API Dokumentation (Swagger)</a><br> <a href="/api-docs" target="_blank" style="color:#1e40af; text-decoration:underline;">REST API Dokumentation (Swagger)</a><br>
© 2025 <a href="mailto:elpatron@mailbox.org?subject=Datumsrechner" style="color:#1e40af; text-decoration:underline;">M. Busche</a> © 2025 <a href="mailto:elpatron@mailbox.org?subject=Datumsrechner" style="color:#1e40af; text-decoration:underline;">Markus Busche</a>
<div style="margin-top:0.5em; font-size:0.85em; color:#64748b;">v{{ app_version }}</div> <div style="margin-top:0.5em; font-size:0.85em; color:#64748b;">v{{ app_version }}</div>
</footer> </footer>
<script>
document.addEventListener('DOMContentLoaded', function() {
var werktageCheckbox = document.getElementById('werktage');
var bundeslandSelect = document.getElementById('bundesland');
if (werktageCheckbox && bundeslandSelect) {
function toggleBundesland() {
bundeslandSelect.disabled = !werktageCheckbox.checked;
}
werktageCheckbox.addEventListener('change', toggleBundesland);
// Initial setzen
toggleBundesland();
}
});
</script>
</body> </body>
</html> </html>

View File

@@ -19,6 +19,32 @@
.stats-label { color: #64748b; } .stats-label { color: #64748b; }
.stats-value { font-size: 1.5em; font-weight: bold; } .stats-value { font-size: 1.5em; font-weight: bold; }
.chart-container { margin: 2em 0; } .chart-container { margin: 2em 0; }
.toggle-container {
display: flex;
justify-content: center;
margin-bottom: 1.5em;
gap: 0.5em;
}
.toggle-btn {
padding: 0.5em 1em;
border: 1px solid #d1d5db;
background: #f9fafb;
color: #6b7280;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.toggle-btn.active {
background: #2563eb;
color: white;
border-color: #2563eb;
}
.toggle-btn:hover {
background: #e5e7eb;
}
.toggle-btn.active:hover {
background: #1d4ed8;
}
</style> </style>
</head> </head>
<body> <body>
@@ -28,6 +54,12 @@
<div class="stats-label">Gesamt-Pageviews (7 Tage):</div> <div class="stats-label">Gesamt-Pageviews (7 Tage):</div>
<div class="stats-value">{{ pageviews }}</div> <div class="stats-value">{{ pageviews }}</div>
</div> </div>
<div class="toggle-container">
<button class="toggle-btn active" data-period="week">Wochenverlauf</button>
<button class="toggle-btn" data-period="day">24-Stunden-Verlauf</button>
</div>
<div class="chart-container"> <div class="chart-container">
<canvas id="imprChart" width="400" height="180"></canvas> <canvas id="imprChart" width="400" height="180"></canvas>
</div> </div>
@@ -43,75 +75,196 @@
</div> </div>
<script type="text/javascript"> <script type="text/javascript">
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Impressions pro Tag // Daten für verschiedene Zeiträume
// eslint-disable-next-line const weekData = {{ impressions_per_day|tojson }};
const imprData = {{ impressions_per_day|tojson }}; const dayData = {{ impressions_per_hour|tojson }};
const imprLabels = Object.keys(imprData); const weekFuncData = {{ func_counts|tojson }};
const imprCounts = Object.values(imprData); const dayFuncData = {{ func_counts_hourly|tojson }};
new Chart(document.getElementById('imprChart').getContext('2d'), { const weekApiData = {{ api_counts|tojson }};
type: 'line', const dayApiData = {{ api_counts_hourly|tojson }};
data: {
labels: imprLabels, let currentPeriod = 'week';
datasets: [{ let currentImprChart = null;
label: 'Impressions/Tag', let currentFuncChart = null;
data: imprCounts, let currentApiChart = null;
borderColor: '#059669',
backgroundColor: 'rgba(5,150,105,0.1)', // Toggle-Buttons Event Listener
tension: 0.2, document.querySelectorAll('.toggle-btn').forEach(btn => {
fill: true btn.addEventListener('click', function() {
}] // Aktiven Button aktualisieren
}, document.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active'));
options: { this.classList.add('active');
plugins: { legend: { display: true } },
scales: { // Zeitraum wechseln
y: { beginAtZero: true, ticks: { stepSize: 1 } } currentPeriod = this.dataset.period;
} updateAllCharts();
} });
}); });
// Funktionsaufrufe
// eslint-disable-next-line function updateImpressionsChart() {
const funcCounts = {{ func_counts|tojson }}; const ctx = document.getElementById('imprChart').getContext('2d');
const labels = Object.keys(funcCounts);
const data = Object.values(funcCounts); // Bestehenden Chart zerstören
new Chart(document.getElementById('funcChart').getContext('2d'), { if (currentImprChart) {
type: 'bar', currentImprChart.destroy();
data: {
labels: labels,
datasets: [{
label: 'Funktionsaufrufe',
data: data,
backgroundColor: '#2563eb',
}]
},
options: {
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, ticks: { stepSize: 1 } }
}
} }
});
// API-Nutzung let data, labels, counts;
// eslint-disable-next-line
const apiCounts = {{ api_counts|tojson }}; if (currentPeriod === 'week') {
if (Object.keys(apiCounts).length > 0 && document.getElementById('apiChart')) { data = weekData;
new Chart(document.getElementById('apiChart').getContext('2d'), { labels = Object.keys(data);
type: 'bar', counts = Object.values(data);
} else {
data = dayData;
labels = Object.keys(data);
counts = Object.values(data);
}
currentImprChart = new Chart(ctx, {
type: 'line',
data: { data: {
labels: Object.keys(apiCounts), labels: labels,
datasets: [{ datasets: [{
label: 'API-Aufrufe nach Endpunkt', label: currentPeriod === 'week' ? 'Impressions/Tag' : 'Impressions/Stunde',
data: Object.values(apiCounts), data: counts,
backgroundColor: '#f59e42', borderColor: '#059669',
backgroundColor: 'rgba(5,150,105,0.1)',
tension: 0.2,
fill: true
}] }]
}, },
options: { options: {
plugins: { legend: { display: false } }, plugins: {
legend: { display: true },
title: {
display: true,
text: currentPeriod === 'week' ? 'Wochenverlauf' : '24-Stunden-Verlauf'
}
},
scales: { scales: {
y: { beginAtZero: true, ticks: { stepSize: 1 } } y: { beginAtZero: true, ticks: { stepSize: 1 } }
} }
} }
}); });
} }
function updateFunctionChart() {
const ctx = document.getElementById('funcChart').getContext('2d');
// Bestehenden Chart zerstören
if (currentFuncChart) {
currentFuncChart.destroy();
}
let data, labels, counts;
if (currentPeriod === 'week') {
data = weekFuncData;
labels = Object.keys(data);
counts = Object.values(data);
} else {
// Für stündliche Daten: Summe aller Stunden für jede Funktion
const aggregatedData = {};
Object.values(dayFuncData).forEach(hourData => {
Object.keys(hourData).forEach(func => {
aggregatedData[func] = (aggregatedData[func] || 0) + hourData[func];
});
});
data = aggregatedData;
labels = Object.keys(data);
counts = Object.values(data);
}
currentFuncChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Funktionsaufrufe',
data: counts,
backgroundColor: '#2563eb',
}]
},
options: {
plugins: {
legend: { display: false },
title: {
display: true,
text: currentPeriod === 'week' ? 'Funktionsaufrufe (Woche)' : 'Funktionsaufrufe (24h)'
}
},
scales: {
y: { beginAtZero: true, ticks: { stepSize: 1 } }
}
}
});
}
function updateApiChart() {
const apiChartElement = document.getElementById('apiChart');
if (!apiChartElement) return;
const ctx = apiChartElement.getContext('2d');
// Bestehenden Chart zerstören
if (currentApiChart) {
currentApiChart.destroy();
}
let data, labels, counts;
if (currentPeriod === 'week') {
data = weekApiData;
} else {
// Für stündliche Daten: Summe aller Stunden für jede API
const aggregatedData = {};
Object.values(dayApiData).forEach(hourData => {
Object.keys(hourData).forEach(api => {
aggregatedData[api] = (aggregatedData[api] || 0) + hourData[api];
});
});
data = aggregatedData;
}
if (Object.keys(data).length === 0) return;
labels = Object.keys(data);
counts = Object.values(data);
currentApiChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'API-Aufrufe nach Endpunkt',
data: counts,
backgroundColor: '#f59e42',
}]
},
options: {
plugins: {
legend: { display: false },
title: {
display: true,
text: currentPeriod === 'week' ? 'API-Nutzung (Woche)' : 'API-Nutzung (24h)'
}
},
scales: {
y: { beginAtZero: true, ticks: { stepSize: 1 } }
}
}
});
}
function updateAllCharts() {
updateImpressionsChart();
updateFunctionChart();
updateApiChart();
}
// Initial Charts erstellen
updateAllCharts();
}); });
</script> </script>
</body> </body>

View File

@@ -221,10 +221,11 @@ def test_api_plusminus(client):
def test_api_stats(client): def test_api_stats(client):
resp = client.get('/api/stats') resp = client.get('/api/stats')
assert resp.status_code == 200 assert resp.status_code == 200
# Die Route gibt HTML zurück, nicht JSON data = resp.get_json()
html = resp.data.decode('utf-8') assert "pageviews" in data
# Prüfe auf typische HTML-Elemente des Dashboards assert "func_counts" in data
assert 'Statistik-Dashboard' in html or 'Dashboard' in html assert "impressions_per_day" in data
assert "api_counts" in data
def test_api_monitor(client): def test_api_monitor(client):
resp = client.get('/api/monitor') resp = client.get('/api/monitor')

View File

@@ -481,3 +481,107 @@ msgstr "Select German"
#: templates/index.html:131 #: templates/index.html:131
msgid "English auswählen" msgid "English auswählen"
msgstr "Select English" msgstr "Select English"
#: templates/index.html:132
msgid "Taschenrechner"
msgstr "Calculator"
#: templates/index.html:133
msgid "Taschenrechner öffnen"
msgstr "Open calculator"
#: templates/index.html:134
msgid "Taschenrechner schließen"
msgstr "Close calculator"
#: templates/index.html:135
msgid "Verwenden Sie die Tab-Taste um durch die Tasten zu navigieren. Tastatur-Kurzbefehle: Zahlen 0-9, Punkt oder Komma für Dezimal, Plus (+) oder P für Addition, Minus (-) oder M für Subtraktion, Stern (*) oder X für Multiplikation, Schrägstrich (/) oder D für Division, Enter oder Leertaste für Gleich, C für Löschen, Backspace für letzte Ziffer löschen."
msgstr "Use the Tab key to navigate through the buttons. Keyboard shortcuts: Numbers 0-9, period or comma for decimal, Plus (+) or P for addition, Minus (-) or M for subtraction, Asterisk (*) or X for multiplication, Slash (/) or D for division, Enter or Space for equals, C for clear, Backspace for delete last digit."
#: templates/index.html:136
msgid "Taschenrechner Anzeige"
msgstr "Calculator display"
#: templates/index.html:137
msgid "Berechnungsverlauf"
msgstr "Calculation history"
#: templates/index.html:138
msgid "Löschen (Taste: C)"
msgstr "Clear (key: C)"
#: templates/index.html:139
msgid "Letzte Ziffer löschen (Taste: Backspace)"
msgstr "Delete last digit (key: Backspace)"
#: templates/index.html:140
msgid "Dividieren (Taste: / oder D)"
msgstr "Divide (key: / or D)"
#: templates/index.html:141
msgid "Multiplizieren (Taste: * oder X)"
msgstr "Multiply (key: * or X)"
#: templates/index.html:142
msgid "Sieben"
msgstr "Seven"
#: templates/index.html:143
msgid "Acht"
msgstr "Eight"
#: templates/index.html:144
msgid "Neun"
msgstr "Nine"
#: templates/index.html:145
msgid "Subtrahieren (Taste: - oder M)"
msgstr "Subtract (key: - or M)"
#: templates/index.html:146
msgid "Vier"
msgstr "Four"
#: templates/index.html:147
msgid "Fünf"
msgstr "Five"
#: templates/index.html:148
msgid "Sechs"
msgstr "Six"
#: templates/index.html:149
msgid "Addieren (Taste: + oder P)"
msgstr "Add (key: + or P)"
#: templates/index.html:150
msgid "Eins"
msgstr "One"
#: templates/index.html:151
msgid "Zwei"
msgstr "Two"
#: templates/index.html:152
msgid "Drei"
msgstr "Three"
#: templates/index.html:153
msgid "Gleich (Taste: Enter oder Leertaste)"
msgstr "Equals (key: Enter or Space)"
#: templates/index.html:154
msgid "Null"
msgstr "Zero"
#: templates/index.html:155
msgid "Komma (Taste: . oder ,)"
msgstr "Decimal (key: . or ,)"
#: templates/index.html:156
msgid "Fehler"
msgstr "Error"
#: templates/index.html:157
msgid "Berechnung"
msgstr "Calculation"