Speiseplan-Kacheln (zukünftige Tage), Footer mit Credit (No tracking, Markus F.J. Busche)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-15 11:18:50 +01:00
parent 5cf75a79bc
commit 073ec27fc7
2 changed files with 109 additions and 6 deletions

30
app.py
View File

@@ -8,7 +8,7 @@ import logging
import os import os
import threading import threading
import time import time
from datetime import datetime from datetime import date, datetime
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from flask import Flask, Response, render_template, request from flask import Flask, Response, render_template, request
@@ -29,20 +29,23 @@ app = Flask(__name__, template_folder=_template_dir)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
# Cache: zuletzt gültige iCal-Bytes; Zeitpunkt der letzten Aktualisierung # Cache: zuletzt gültige iCal-Bytes; strukturierte Daten (Datum → Gerichte); Zeitpunkt der letzten Aktualisierung
_ical_cache: bytes = empty_ical_bytes() _ical_cache: bytes = empty_ical_bytes()
_by_date_cache: dict[date, list[str]] | None = None
_last_refresh_at: datetime | None = None _last_refresh_at: datetime | None = None
_cache_lock = threading.Lock() _cache_lock = threading.Lock()
_TIMEZONE = ZoneInfo("Europe/Berlin") _TIMEZONE = ZoneInfo("Europe/Berlin")
_WEEKDAY_NAMES = ("Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag")
def _do_refresh() -> None: def _do_refresh() -> None:
"""Refresh ausführen und bei Erfolg Cache aktualisieren.""" """Refresh ausführen und bei Erfolg Cache aktualisieren."""
global _ical_cache, _last_refresh_at global _ical_cache, _by_date_cache, _last_refresh_at
result = refresh_speiseplan(KANTINE_BASE_URL) result = refresh_speiseplan(KANTINE_BASE_URL)
if result is not None: if result is not None:
_, ical_bytes = result by_date, ical_bytes = result
with _cache_lock: with _cache_lock:
_ical_cache = ical_bytes _ical_cache = ical_bytes
_by_date_cache = by_date
_last_refresh_at = datetime.now(_TIMEZONE) _last_refresh_at = datetime.now(_TIMEZONE)
_log.info("Speiseplan-Refresh erfolgreich, Cache aktualisiert.") _log.info("Speiseplan-Refresh erfolgreich, Cache aktualisiert.")
else: else:
@@ -105,17 +108,34 @@ def _format_last_refresh() -> str | None:
return t.strftime("%d.%m.%Y, %H:%M") + " Uhr" return t.strftime("%d.%m.%Y, %H:%M") + " Uhr"
def _upcoming_days() -> list[tuple[str, list[str]]]:
"""Heute und zukünftige Tage aus dem Cache, sortiert, mit formatiertem Datum (z. B. Montag, 03.02.2026)."""
with _cache_lock:
by_date = _by_date_cache
if not by_date:
return []
today = datetime.now(_TIMEZONE).date()
upcoming = [(d, dishes) for d, dishes in by_date.items() if d >= today]
upcoming.sort(key=lambda x: x[0])
return [
(f"{_WEEKDAY_NAMES[d.weekday()]}, {d.strftime('%d.%m.%Y')}", dishes)
for d, dishes in upcoming
]
@app.route("/") @app.route("/")
def index(): def index():
"""Startseite mit Anleitung zur iCal-Einbettung (Google und andere).""" """Startseite mit Anleitung zur iCal-Einbettung (Google und andere) und Speiseplan-Kacheln."""
base = PUBLIC_URL or request.url_root.rstrip("/") base = PUBLIC_URL or request.url_root.rstrip("/")
calendar_url = f"{base}/calendar.ics" calendar_url = f"{base}/calendar.ics"
last_refresh_str = _format_last_refresh() last_refresh_str = _format_last_refresh()
upcoming_days = _upcoming_days()
try: try:
return render_template( return render_template(
"index.html", "index.html",
calendar_url=calendar_url, calendar_url=calendar_url,
last_refresh_str=last_refresh_str, last_refresh_str=last_refresh_str,
upcoming_days=upcoming_days,
) )
except Exception as e: except Exception as e:
_log.exception("Template index.html: %s", e) _log.exception("Template index.html: %s", e)

View File

@@ -195,6 +195,18 @@
color: var(--text-muted); color: var(--text-muted);
} }
footer p {
margin: 0 0 0.5rem;
}
footer p:last-child {
margin-bottom: 0;
}
.footer-credit {
margin-top: 0.75rem;
}
footer a { footer a {
color: var(--accent); color: var(--accent);
text-decoration: none; text-decoration: none;
@@ -203,6 +215,56 @@
footer a:hover { footer a:hover {
text-decoration: underline; text-decoration: underline;
} }
.speiseplan-section {
margin-bottom: 2.5rem;
}
.speiseplan-section h2 {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 1rem;
}
.speiseplan-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
.day-tile {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem 1.5rem;
}
.day-tile h3 {
font-size: 1rem;
font-weight: 600;
margin: 0 0 0.75rem;
color: var(--accent);
}
.day-tile .dishes {
font-size: 0.9rem;
color: var(--text-muted);
line-height: 1.5;
}
.day-tile .dishes div {
margin-bottom: 0.35rem;
}
.day-tile .dishes div:last-child {
margin-bottom: 0;
}
.speiseplan-empty {
color: var(--text-muted);
font-size: 0.95rem;
margin: 0;
}
</style> </style>
</head> </head>
<body> <body>
@@ -217,6 +279,26 @@
{% endif %} {% endif %}
</header> </header>
<section class="speiseplan-section">
<h2>Speiseplan (zukünftige Tage)</h2>
{% if upcoming_days %}
<div class="speiseplan-grid">
{% for date_str, dishes in upcoming_days %}
<article class="day-tile">
<h3>{{ date_str }}</h3>
<div class="dishes">
{% for dish in dishes %}
<div>{{ dish }}</div>
{% endfor %}
</div>
</article>
{% endfor %}
</div>
{% else %}
<p class="speiseplan-empty">Es liegen derzeit (noch) keine neuen Speisepläne vor.</p>
{% endif %}
</section>
<section class="card"> <section class="card">
<h2><span class="num">1</span> Abo-URL kopieren</h2> <h2><span class="num">1</span> Abo-URL kopieren</h2>
<p style="margin: 0 0 1rem; font-size: 0.95rem; color: var(--text-muted);">Diese URL in Ihrem Kalender als „Abonnement“ oder „Von URL hinzufügen“ eintragen:</p> <p style="margin: 0 0 1rem; font-size: 0.95rem; color: var(--text-muted);">Diese URL in Ihrem Kalender als „Abonnement“ oder „Von URL hinzufügen“ eintragen:</p>
@@ -259,7 +341,8 @@
</section> </section>
<footer> <footer>
Quelle: <a href="http://kantine-bhz.de" target="_blank" rel="noopener">kantine-bhz.de</a>. Der Speiseplan wird einmal täglich aktualisiert. Direkter Kalender-Download: <a href="{{ calendar_url }}">calendar.ics</a>. <p>Quelle: <a href="http://kantine-bhz.de" target="_blank" rel="noopener">kantine-bhz.de</a>. Der Speiseplan wird einmal täglich aktualisiert. Direkter Kalender-Download: <a href="{{ calendar_url }}">calendar.ics</a>.</p>
<p class="footer-credit">No tracking, no cookies. Made as a hobby project in 2026 by <a href="mailto:elpatron@mailbox.org">Markus F.J. Busche</a>.</p>
</footer> </footer>
</div> </div>