Zweite Live-Uhr fuer "spezielles Live-Event" + mehr Farbe im Radar

Nutzer-Wunsch 20.08.2026: "wuensch mir nur noch bissl farbe und sehr
wichtig ist dass ich auch spezielle live events da auch eintragen kann
in der verwaltungsseite so dass da eine zweite uhr erscheint wo die zeit
dann fuer dieses spezielle event laeuft."

- Neue Verwaltungs-Sektion "Spezielles Live-Event": Titel (Deutsch, wird
  automatisch uebersetzt), echtes Datum+Uhrzeit, optionaler Link, Aktiv-
  Schalter -- nur mit Titel + Zukunftsdatum aktivierbar.
- Zweite Uhr auf streamplan.html (Magenta/Violett statt Cyan/Gold, direkt
  neben der bestehenden), nur sichtbar wenn ein Event aktiviert ist und
  das Zieldatum noch nicht vorbei ist. Echter Countdown inkl. Tage (z.B.
  "2T 03:14:59"), "Jetzt"-Zeiger zeigt wie bei Uhr 1 die tatsaechliche
  Uhrzeit, der magentafarbene Fixpunkt markiert die Tageszeit des Events.
  Zieldatum wird als UTC gespeichert -- jede besuchende Person sieht den
  exakt richtigen Countdown in ihrer eigenen Zeitzone.
- "Bissl Farbe": bunter Farbverlauf (Cyan/Violett/Gold) auf dem aeusseren
  Ring statt reinem Grauton, kraeftigerer Sweep-Farbschein.
- Backend: neue routes/special-event.js (getSpecialEventPublic nur bei
  aktiv+zukuenftig, getSpecialEventAdmin fuer Entwuerfe, saveSpecialEvent
  mit Validierung), 13 automatisierte Tests gegen eine Fake-DB bestanden.
- Bugfix unterwegs gefunden (Playwright-Screenshot, wiederholtes Muster
  auf dieser Seite): .stream-radar-wrap blieb trotz [hidden]-Attribut
  sichtbar (display:block ueberschreibt die eingebaute [hidden]-Regel bei
  gleicher Spezifitaet) -- explizite Regel ergaenzt, per Playwright erneut
  verifiziert (display:none bestaetigt).

Backend-Teil (server-internal/) noch ohne Deploy-Zugriff -- Dogi muss ihn
manuell auf dogiintern ausrollen, sonst bleibt die zweite Uhr unsichtbar.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-20 23:24:17 +02:00
co-authored by Claude Opus 5
parent 7fdceadb9e
commit 519e209bce
40 changed files with 763 additions and 369 deletions
+6
View File
@@ -64,6 +64,7 @@ import {
listSupporters, getSupporterDetail, updatePremium, listCollections, upsertCollection, getAdminOverview,
} from "./routes/supporter-admin.js";
import { getEventsPublic, getEventsAdmin, saveEvent, uploadEventImage } from "./routes/events.js";
import { getSpecialEventPublic, getSpecialEventAdmin, saveSpecialEvent } from "./routes/special-event.js";
import {
createPoll, listPolls, closePoll, reopenPoll, deletePoll, getActivePollPublic, voteOnPoll,
} from "./routes/polls.js";
@@ -157,6 +158,7 @@ app.post("/submit", wrap(submitApplication));
app.get("/live-status", wrap(handleLiveStatusGet));
app.post("/auth/login", wrap(login));
app.get("/events-of-year", wrap(getEventsPublic));
app.get("/special-event", wrap(getSpecialEventPublic));
app.post("/supporter/register", wrap(registerSupporter));
app.post("/supporter/verify-email", wrap(verifySupporterEmail));
@@ -194,6 +196,10 @@ app.post("/admin/events/list", requireTeamSession, withSession(getEventsAdmin));
app.post("/admin/events/save", requireTeamSession, withSession(saveEvent));
app.post("/admin/events/upload-image", requireTeamSession, bildUpload.single("image"), withSession(uploadEventImage));
// Spezielles Live-Event mit Countdown auf streamplan.html (20.08.2026)
app.post("/admin/special-event", requireTeamSession, withSession(getSpecialEventAdmin));
app.post("/admin/special-event/save", requireTeamSession, withSession(saveSpecialEvent));
app.post("/admin/roles/list", requireTeamSession, withSession(listRoles));
app.post("/admin/roles/create", requireTeamSession, withSession(createRole));
app.post("/admin/roles/update", requireTeamSession, withSession(updateRole));
+97
View File
@@ -0,0 +1,97 @@
/* =====================================================================
routes/special-event.js — "Spezielles Live-Event" für streamplan.html
(20.08.2026, Nutzer-Wunsch: "sehr wichtig ist dass ich auch spezielle
live events da auch eintragen kann in der verwaltungsseite so dass da
eine zweite uhr erscheint wo die zeit dann für dieses spezielle event
läuft"). Anders als "Event des Jahres" (2 feste Slots) gibt es hier
GENAU EIN Event mit einem exakten Zieldatum+Uhrzeit statt nur einem
freien Datumstext — daraus lässt sich ein echter Countdown berechnen.
Läuft auf derselben app_settings-Tabelle (key/value), kein Schema-Umbau
nötig. Gleiche Rechte-/Freigabe-Logik wie "Event des Jahres"
(EVENTS_MANAGE, Owner immer erlaubt, öffentlich lesbar, aber nur wenn
aktiviert UND das Zieldatum noch in der Zukunft liegt). ===================================================================== */
import { db } from "../db.js";
import { hasPermission } from "../lib/permissions.js";
import { json } from "../lib/http.js";
import { logAction } from "../lib/audit.js";
import { uebersetzeAlleSprachen } from "../lib/translate.js";
const SETTINGS_KEY = "special_live_event";
function can(session, key) {
if (!session) return false;
if (session.isOwner) return true;
return hasPermission(session.permissions, session.overrides, key);
}
function forbidden(res) {
return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
}
function laden() {
const row = db.prepare(`SELECT value FROM app_settings WHERE key = ?`).get(SETTINGS_KEY);
if (!row) return null;
try {
return JSON.parse(row.value);
} catch {
return null;
}
}
/** Öffentlich, keine Session: nur wenn aktiviert UND das Zieldatum noch
nicht vorbei ist (ein abgelaufenes Event soll nicht ewig als "Countdown
bei 00:00:00" hängen bleiben, sondern einfach verschwinden). */
export async function getSpecialEventPublic(req, res) {
const eintrag = laden();
const istGueltig = eintrag && eintrag.aktiv && new Date(eintrag.zieldatum) > new Date();
return json(res, { ok: true, event: istGueltig ? eintrag : null });
}
/** Für die Verwaltung: liefert den rohen Stand (auch inaktiv/abgelaufen),
damit das Formular beim Öffnen alles vorausfüllen kann. */
export async function getSpecialEventAdmin(req, res, session) {
if (!can(session, "EVENTS_MANAGE")) return forbidden(res);
return json(res, { ok: true, event: laden() });
}
export async function saveSpecialEvent(req, res, session) {
if (!can(session, "EVENTS_MANAGE")) return forbidden(res);
const body = req.body || {};
const titelDe = String(body.titel || "").trim().slice(0, 120);
const zieldatumRoh = String(body.zieldatum || "").trim();
const link = String(body.link || "").trim().slice(0, 300) || null;
const aktiv = !!body.aktiv;
if (!titelDe) return json(res, { ok: false, error: "Titel ist Pflicht." }, 400);
const zieldatum = new Date(zieldatumRoh);
if (!zieldatumRoh || Number.isNaN(zieldatum.getTime())) {
return json(res, { ok: false, error: "Bitte ein gültiges Datum + Uhrzeit angeben." }, 400);
}
if (link && !/^https?:\/\//i.test(link)) {
return json(res, { ok: false, error: "Link muss mit http:// oder https:// beginnen." }, 400);
}
if (aktiv && zieldatum <= new Date()) {
return json(res, { ok: false, error: "Das Zieldatum liegt in der Vergangenheit — zum Aktivieren muss es in der Zukunft liegen." }, 400);
}
const titel = await uebersetzeAlleSprachen(titelDe);
const eintrag = {
titel,
zieldatum: zieldatum.toISOString(),
link,
aktiv,
aktualisiertVon: session.actor,
aktualisiertAm: new Date().toISOString(),
};
db.prepare(
`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
).run(SETTINGS_KEY, JSON.stringify(eintrag), eintrag.aktualisiertAm);
logAction(session.actor, "special_event.saved", null, { titel: titelDe, zieldatum: eintrag.zieldatum, aktiv });
return json(res, { ok: true, event: eintrag });
}