Zum Inhalt
← Sektionen & Funktionen

Programm-Kalender mit Liste und Monat

Programm eines Kulturhauses als Liste und Monat mit Rubrik-Filter, echter .ics-Datei je Termin, gedimmter Vergangenheit und Ausverkauft-Zustand.

Programmseite eines Hauses mit vielen Terminen über Wochen (Kulturhaus, Theater, Kursanbieter).

Das Programm eines Hauses als eine Sektion mit zwei Ansichten: eine Liste nach Datum zum Überfliegen und eine Monatsansicht zum Planen. Ein Umschalter wechselt die Ansicht, Rubrik-Filter wirken in beiden. Jeder Termin zeigt Datum, Uhrzeit, Ort, Rubrik als Text und Farbe, Preis und Ticket-Status. „In Kalender“ erzeugt im Browser eine echte .ics-Datei und lädt sie herunter. Vergangene Termine bleiben sichtbar, aber gedimmt und einzeilig. Ausverkaufte Termine sind als Text gekennzeichnet und bieten die Warteliste an. Ist ein Filter leer, zeigt die Sektion einen Ausweg statt einer leeren Fläche.

Besucher suchen zuerst Datum, Uhrzeit und Ticket, erst dann das Bild. Die Liste beantwortet „Was läuft bald?“, der Monat „Was passt in meine Woche?“. Ein gemeinsamer Filter für beide Ansichten spart Wege. Der Kalender-Export macht aus Interesse einen festen Termin.

Wann einsetzen

Kulturhaus, Kunsthalle, Festival, Theater, Kino, Kursanbieter mit festen Terminen, Restaurant oder Hotel mit Veranstaltungen. Ab etwa acht Terminen im Monat.

Wann nicht

Bei zwei oder drei Terminen im Jahr reicht eine einfache Liste. Liefert das Ticketsystem (Spektrix, Enviso u. a.) eine eigene Programmseite, die Sektion nur als Teaser bauen und dorthin verlinken. Dauerangebote (Ausstellung täglich geöffnet) gehören nicht als Einzeltermine in den Monat.

Varianten

  • Liste und Monat mit Umschalter, Filter nach Rubrik (Programmseite eines Hauses)
  • Tagesleiste: Tage als Reiter, darunter die Termine des Tages (Festival, Kino, Messe)
  • Teaser „Nächste Termine“ mit drei Karten und Link zum Programm (Startseite)
  • Kursprogramm mit freien Plätzen und Warteliste statt Ticketpreis

Umsetzung

Daten als Liste von Terminen (id, tag, zeit, dauer, rubrik, titel, ort, preis, status). Ein Filterzustand, je Ansicht eine Render-Funktion. Kalenderrechnung in UTC (Date.UTC), damit die Zeitzone des Geräts keine Tage verschiebt; Anzeige mit Intl.DateTimeFormat('de-DE', { timeZone: 'UTC' }).

Liste: <ol> der Tage, je Tag eine <ol> der Termine. Datum und Uhrzeit in Mono mit tabular-nums. Vergangene Tage kompakt und gedimmt, „Heute“ als Marke.

Monat: <ol> mit 7 Spalten, Montag zuerst, Tage mit Terminen als <button aria-pressed> mit vollständigem Datum im zugänglichen Namen. Darunter die Termine des gewählten Tages in derselben Darstellung wie die Liste.

Linien: Fallback mit gap: 1px auf Linienfarbe. Verbesserung: @supports (row-rule: 1px solid) { .raster { background: none; column-rule: 1px solid; row-rule: 1px solid; rule-break: intersection; rule-inset-junction: 5px } }. Gap Decorations sind rein dekorativ.

ICS: Zeilen mit CRLF, Faltung nach 75 Oktetten (TextEncoder, UTF-8 nie zerteilen), Text maskieren (Backslash, Semikolon, Komma, Zeilenumbruch mit vorangestelltem Backslash), VTIMEZONE für Europe/Berlin mitliefern, DTSTART/DTEND;TZID=Europe/Berlin als Wandzeit, DTSTAMP in UTC, stabile UID. new Blob([text], { type: 'text/calendar;charset=utf-8' }), URL.createObjectURL, <a download>, danach revokeObjectURL. Eine Datei kann mehrere VEVENT enthalten (z. B. „Auswahl in den Kalender“).

Im Einsatz: Jetzt aus new Date() in Europe/Berlin, Programm aus dem CMS oder dem Ticketsystem, Filter und Ansicht in der URL (?rubrik=musik&ansicht=monat), damit Links teilbar sind.

Robust umsetzen

Telefon. Rubriken als wischbare Leiste. Der Monat wird zur Punkt-Übersicht, ein Tipp auf den Tag zeigt die Termine darunter. Vergangene Termine einzeilig. Tagesleiste wischbar mit Scroll-Snap.

Ohne Bewegung. Ansichten und Listen erscheinen ohne Einblenden, die Segment-Pille springt. Zustände und Texte bleiben gleich.

Barrierefrei. Rubrik nie nur über Farbe: immer ein Text-Label auf weißer Schrift (AA auf jeder Spartenfarbe). Filter als Schalter mit aria-pressed, Anzahl je Rubrik mit Label. Ansicht als Radiogruppe in fieldset mit legend. Trefferzahl und Monatsname in aria-live. Tage im Monat als Knöpfe mit vollständigem Datum und Terminzahl im Namen. „Ausverkauft“, „Vorbei“ und „Heute“ als Text, nicht nur als Dimmen oder Farbe. Der Kalender-Knopf nennt Titel und Datum im zugänglichen Namen. Bei sehr vielen Tagen im Monat roving tabindex mit Pfeiltasten erwägen.

Code

<!DOCTYPE html>
<!--
  Programm-Kalender mit Liste und Monat
  Fundus · Sektionen & Funktionen · https://cecodesign.de/lab/fundus/sektionen/programm-kalender/
  Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Programm-Kalender mit Liste und Monat · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Schibsted Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/schibsted-grotesk:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 900; font-display: swap; }
@font-face { font-family: 'Atkinson Hyperlegible Next'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/atkinson-hyperlegible-next:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-mono@latest/latin-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; }

:root {
  /* Rezept kultur-programm-haltung: Schwarz auf warmem Off-White, je Sparte eine Farbe (immer mit Text-Label) */
  --grund: #efede9; --flaeche: #e4e1db; --tinte: #0b0b0b; --leise: #55524c; --linie: rgba(11, 11, 11, .16); --linie-stark: #0b0b0b;
  --musik: #c4421a; --buehne: #3d5f9a; --kunst: #2f7d4a; --film: #b0186b;
  --kurve: cubic-bezier(0.215, 0.61, 0.355, 1);   /* easeOutCubic, kurz und ohne Überschwingen */
  --ui: 200ms; --ansicht: 320ms;
  --kante: clamp(16px, 3.2vw, 44px);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--tinte); font-family: 'Atkinson Hyperlegible Next', system-ui, sans-serif; font-size: 100%; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; }
button, input { font: inherit; color: inherit; }
button { cursor: pointer; background: none; border: 0; }
a { color: inherit; }
.mono { font-family: 'IBM Plex Mono', ui-monospace, monospace; font-variant-numeric: tabular-nums; }
.label { font: 500 .75rem/1.2 'IBM Plex Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
:focus-visible { outline: 2px solid var(--tinte); outline-offset: 3px; }

/* ---------- Kopf des Hauses ---------- */
.haus { position: sticky; top: 0; z-index: 5; display: flex; align-items: center; justify-content: space-between; gap: 24px; height: 72px; padding: 0 var(--kante); background: var(--grund); border-bottom: 1px solid var(--linie-stark); }
.marke { display: flex; align-items: center; gap: 12px; text-decoration: none; }
.marke svg { width: 42px; height: 26px; flex: none; }
.marke b { font: 900 1.375rem/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.01em; }
.marke small { font-size: .6875rem; display: block; margin-top: 3px; color: var(--leise); letter-spacing: .1em; }
.haus nav ul { display: flex; gap: 28px; list-style: none; font-size: 1rem; }
.haus nav a { text-decoration: none; padding: 6px 0; border-bottom: 2px solid transparent; }
.haus nav a[aria-current] { border-color: var(--tinte); font-weight: 700; }
.haus nav a:hover { border-color: var(--leise); }
.ticket { font: 700 .9375rem/1 'Schibsted Grotesk', sans-serif; background: var(--tinte); color: var(--grund); padding: 13px 18px; text-decoration: none; }

/* ---------- Programmkopf ---------- */
.programm { padding: clamp(28px, 4vw, 56px) var(--kante) 120px; max-width: 1500px; margin: 0 auto; }
.programm-kopf { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 30rem); gap: 20px 48px; align-items: end; padding-bottom: clamp(20px, 3vw, 36px); }
h1 { font: 850 clamp(2.75rem, 7vw, 7rem)/.92 'Schibsted Grotesk', sans-serif; letter-spacing: -.02em; }
h1 span { display: block; font-size: .32em; letter-spacing: -.005em; font-weight: 700; margin-top: .35em; }
.vorspann { font-size: 1.125rem; line-height: 1.55; max-width: 44ch; text-wrap: pretty; }
.vorspann small { display: block; margin-top: 10px; font-size: .875rem; color: var(--leise); }

/* ---------- Steuerung ---------- */
.steuerung { position: sticky; top: 72px; z-index: 4; background: var(--grund); display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 14px 28px; align-items: center; padding: 14px 0; border-top: 1px solid var(--linie-stark); border-bottom: 1px solid var(--linie); }
.monatswahl { display: flex; align-items: center; gap: 6px; }
.monatswahl h2 { font: 800 1.5rem/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.01em; min-width: 8.6em; text-align: center; }
.pfeil { width: 40px; height: 40px; display: grid; place-items: center; border: 1px solid var(--linie-stark); transition: background-color var(--ui) var(--kurve), color var(--ui) var(--kurve); }
.pfeil svg { width: 16px; height: 16px; }
.pfeil:hover:not(:disabled) { background: var(--tinte); color: var(--grund); }
.pfeil:disabled { opacity: .3; cursor: not-allowed; }
.rubriken { display: flex; flex-wrap: wrap; gap: 6px; list-style: none; }
.rubrik { display: inline-flex; align-items: center; gap: 8px; height: 40px; padding: 0 14px; border: 1px solid var(--linie-stark); font-size: .9375rem; font-weight: 600; transition: background-color var(--ui) var(--kurve), color var(--ui) var(--kurve), border-color var(--ui) var(--kurve); }
.rubrik i { width: 10px; height: 10px; background: var(--farbe, var(--tinte)); flex: none; transition: background-color var(--ui) var(--kurve); }
.rubrik .zahl { font: 500 .75rem/1 'IBM Plex Mono', monospace; color: var(--leise); }
.rubrik:hover { background: var(--flaeche); }
.rubrik[aria-pressed="true"] { background: var(--farbe, var(--tinte)); border-color: var(--farbe, var(--tinte)); color: #fff; }
.rubrik[aria-pressed="true"] i { background: #fff; }
.rubrik[aria-pressed="true"] .zahl { color: rgba(255, 255, 255, .82); }
.rubrik[data-r="alle"] { --farbe: var(--tinte); }
.rechts { display: flex; align-items: center; gap: 18px; }
.treffer { font-size: .9375rem; color: var(--leise); white-space: nowrap; min-width: 9.5em; text-align: right; }
.treffer b { color: var(--tinte); font-weight: 700; font-variant-numeric: tabular-nums; }

/* Ansicht: Segment mit gleitender Pille (zwei gleich breite Felder) */
.ansicht { position: relative; display: grid; grid-template-columns: 1fr 1fr; border: 1px solid var(--linie-stark); padding: 3px; isolation: isolate; }
.ansicht legend { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.ansicht::before { content: ''; position: absolute; z-index: -1; top: 3px; bottom: 3px; left: 3px; width: calc(50% - 3px); background: var(--tinte); transition: translate 250ms var(--kurve); }
.ansicht:has(input[value="monat"]:checked)::before { translate: 100% 0; }
.ansicht label { position: relative; display: flex; align-items: center; justify-content: center; gap: 7px; height: 32px; padding: 0 14px; font-size: .9375rem; font-weight: 600; cursor: pointer; transition: color 250ms var(--kurve); }
.ansicht label:has(:checked) { color: var(--grund); }
.ansicht label:has(:focus-visible) { outline: 2px solid var(--tinte); outline-offset: 4px; }
.ansicht input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }
.ansicht svg { width: 15px; height: 15px; }
[data-variante="tage"] .ansicht { display: none; }

/* ---------- Tagesleiste (Variante) ---------- */
.tagesleiste { display: none; gap: 6px; overflow-x: auto; scrollbar-width: thin; padding: 16px 0 4px; list-style: none; scroll-snap-type: x proximity; }
[data-variante="tage"] .tagesleiste { display: flex; }
.tagesleiste button { scroll-snap-align: start; flex: none; display: grid; grid-template-rows: auto auto auto; justify-items: start; gap: 4px; min-width: 76px; padding: 10px 12px 9px; border: 1px solid var(--linie); text-align: left; transition: background-color var(--ui) var(--kurve), color var(--ui) var(--kurve), border-color var(--ui) var(--kurve); }
.tagesleiste .wt { font: 500 .6875rem/1 'IBM Plex Mono', monospace; letter-spacing: .12em; text-transform: uppercase; color: var(--leise); }
.tagesleiste .nr { font: 800 1.625rem/1 'Schibsted Grotesk', sans-serif; font-variant-numeric: tabular-nums; }
.tagesleiste .punkte { display: flex; gap: 3px; height: 6px; }
.tagesleiste .punkte i { width: 6px; height: 6px; background: var(--farbe); }
.tagesleiste button:hover { border-color: var(--tinte); }
.tagesleiste button[aria-pressed="true"] { background: var(--tinte); color: var(--grund); border-color: var(--tinte); }
.tagesleiste button[aria-pressed="true"] .wt { color: rgba(239, 237, 233, .7); }
.tagesleiste button.vorbei, .tagesleiste button.vorbei .wt { color: #6b6862; }
.tagesleiste button.vorbei .punkte { opacity: .5; }

/* ---------- Ansichten ---------- */
.ansichten { position: relative; }
.flaeche[hidden] { display: none; }
.flaeche { animation: auftritt var(--ansicht) var(--kurve) both; }
@keyframes auftritt { from { opacity: 0; translate: 0 8px; } }
.neu { animation: auftritt var(--ansicht) var(--kurve) both; animation-delay: calc(var(--i, 0) * 24ms); }

/* Liste */
.tage { list-style: none; }
.tag { display: grid; grid-template-columns: 9.5rem minmax(0, 1fr); gap: 0 28px; padding: 22px 0; border-bottom: 1px solid var(--linie); }
.tag-datum { display: grid; grid-template-columns: auto 1fr; align-items: baseline; gap: 2px 10px; align-content: start; }
.tag-datum .nr { font: 850 3rem/.86 'Schibsted Grotesk', sans-serif; font-variant-numeric: tabular-nums; letter-spacing: -.02em; grid-row: span 2; }
.tag-datum .wt { font: 500 .8125rem/1.2 'IBM Plex Mono', monospace; letter-spacing: .1em; text-transform: uppercase; }
.tag-datum .marker { justify-self: start; font: 500 .6875rem/1 'IBM Plex Mono', monospace; letter-spacing: .12em; text-transform: uppercase; padding: 4px 6px 3px; background: var(--tinte); color: var(--grund); }
.tag-datum .marker.leise { background: none; color: var(--leise); padding-left: 0; }
.termine { list-style: none; display: grid; gap: 16px; }
.termin { display: grid; grid-template-columns: 7.2rem minmax(0, 1fr) auto; gap: 6px 24px; align-items: start; }
.zeit { font: 500 .9375rem/1.5 'IBM Plex Mono', monospace; font-variant-numeric: tabular-nums; padding-top: 3px; }
.zeit small { display: block; font-size: .8125rem; color: var(--leise); }
.chip { display: inline-flex; align-items: center; height: 22px; padding: 0 7px; font: 500 .6875rem/1 'IBM Plex Mono', monospace; letter-spacing: .12em; text-transform: uppercase; background: var(--farbe); color: #fff; }
.termin h3 { font: 750 clamp(1.375rem, 2.2vw, 2rem)/1.08 'Schibsted Grotesk', sans-serif; letter-spacing: -.012em; margin: 8px 0 6px; text-wrap: balance; }
.termin .unter { font-size: 1.0625rem; line-height: 1.5; max-width: 60ch; text-wrap: pretty; }
.termin .meta { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: 8px; font: 400 .8125rem/1.4 'IBM Plex Mono', monospace; color: var(--leise); font-variant-numeric: tabular-nums; }
.termin .meta .knapp { color: #b83e18; font-weight: 500; }
.aktionen { display: flex; flex-direction: column; align-items: stretch; gap: 6px; min-width: 12.5rem; padding-top: 2px; }
.knopf { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 44px; padding: 0 16px; font: 700 .9375rem/1 'Schibsted Grotesk', sans-serif; text-decoration: none; border: 1px solid var(--tinte); transition: background-color var(--ui) var(--kurve), color var(--ui) var(--kurve), scale 150ms var(--kurve); }
.knopf svg { width: 16px; height: 16px; flex: none; }
.knopf.voll { background: var(--tinte); color: var(--grund); }
.knopf.voll:hover { background: #2a2926; }
.knopf.rand:hover { background: var(--tinte); color: var(--grund); }
.knopf:active, .knopf.drueckt { scale: .97; }
.knopf.gespeichert { background: var(--kunst); border-color: var(--kunst); color: #fff; }
.status-aus { display: flex; align-items: center; justify-content: center; gap: 8px; height: 44px; font: 700 .9375rem/1 'Schibsted Grotesk', sans-serif; background: repeating-linear-gradient(-45deg, transparent 0 6px, rgba(11, 11, 11, .07) 6px 7px); border: 1px dashed var(--leise); }
.vorbei-zeile { font: 500 .8125rem/44px 'IBM Plex Mono', monospace; letter-spacing: .1em; text-transform: uppercase; color: var(--leise); text-align: center; border: 1px solid var(--linie); }
/* Vorbei: gedämpft über Farbe statt Deckkraft, damit der Text lesbar bleibt (4,7:1) */
.tag.vorbei .tag-datum, .tag.vorbei .termin > :not(.aktionen), .tag.vorbei .tag-datum :is(.wt, .nr), .tag.vorbei .zeit small { color: #6b6862; }
.tag.vorbei .chip { background: color-mix(in srgb, var(--farbe) 50%, #6b6862); }
.tag.kompakt { padding: 12px 0; align-items: baseline; }
.tag.kompakt .tag-datum { grid-template-columns: 2.4rem auto 1fr; align-items: baseline; }
.tag.kompakt .tag-datum .marker { display: none; }
.tag.kompakt .termin { grid-template-columns: 7.2rem minmax(0, 1fr) auto; }
.tag.kompakt .tag-datum .nr { font-size: 1.5rem; grid-row: auto; }
.tag.kompakt .termine { gap: 6px; }
.tag.kompakt .termin { align-items: baseline; }
.tag.kompakt .termin > div:not(.aktionen) { display: flex; gap: 12px; align-items: baseline; min-width: 0; }
.tag.kompakt .chip { flex: none; translate: 0 -2px; }
.tag.kompakt h3 { font-size: 1.125rem; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tag.kompakt .termin.aus h3::after { content: none; }
.tag.kompakt .zeit { padding: 0; }
.tag.kompakt .zeit small, .tag.kompakt .unter, .tag.kompakt .meta { display: none; }
.tag.kompakt .aktionen { grid-column: auto; min-width: 0; padding: 0; }
.tag.kompakt .vorbei-zeile { line-height: 1.4; border: 0; text-align: right; }
.termin.aus h3::after { content: 'Ausverkauft'; display: inline-block; vertical-align: .32em; margin-left: 10px; font: 500 .6875rem/1 'IBM Plex Mono', monospace; letter-spacing: .12em; text-transform: uppercase; padding: 4px 6px 3px; border: 1px solid currentColor; }

/* Monat */
.wochentage { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); padding: 18px 0 8px; }
.wochentage span { font: 500 .75rem/1 'IBM Plex Mono', monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); padding-left: 10px; }
.raster { list-style: none; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); grid-auto-rows: minmax(104px, auto); border-top: 1px solid var(--linie-stark); border-bottom: 1px solid var(--linie-stark);
  /* Fallback: Linien als Hintergrund durch 1px-Lücken */
  gap: 1px; background: var(--linie); }
.raster > li { background: var(--grund); min-width: 0; }
@supports (row-rule: 1px solid) {
  /* Gap Decorations: Linien nur zwischen den Zellen, an Kreuzungen kurz unterbrochen */
  .raster { background: none; column-rule: 1px solid var(--linie); row-rule: 1px solid var(--linie); rule-break: intersection; rule-inset-junction: 5px; rule-inset-cap: 0px; }
}
.zelle { position: relative; display: flex; flex-direction: column; align-items: stretch; gap: 4px; width: 100%; height: 100%; padding: 9px 9px 10px; text-align: left; transition: background-color var(--ui) var(--kurve); }
button.zelle:hover { background: var(--flaeche); }
.zelle .nr { font: 500 .875rem/1 'IBM Plex Mono', monospace; font-variant-numeric: tabular-nums; margin-bottom: 4px; display: flex; justify-content: space-between; align-items: center; }
.zelle.fremd .nr { color: rgba(11, 11, 11, .28); }
.zelle.vorbei { color: #6b6862; }
.zelle.vorbei .eintrag::before { opacity: .5; }
.zelle.heute .nr b { background: var(--tinte); color: var(--grund); padding: 3px 5px 2px; margin: -3px -5px -2px; font-weight: 500; }
.zelle.heute .nr::after { content: 'heute'; font-size: .6875rem; letter-spacing: .1em; text-transform: uppercase; }
.zelle[aria-pressed="true"] { box-shadow: inset 0 0 0 2px var(--tinte); }
.eintrag { display: grid; grid-template-columns: 4px minmax(0, 1fr); gap: 7px; font-size: .8125rem; line-height: 1.25; }
.eintrag::before { content: ''; background: var(--farbe); }
.eintrag span { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
.eintrag em { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.eintrag em { font: 500 .75rem/1 'IBM Plex Mono', monospace; font-style: normal; display: block; margin-bottom: 2px; color: var(--leise); }
.eintrag.aus::before { background: repeating-linear-gradient(180deg, var(--farbe) 0 3px, transparent 3px 5px); }
.eintrag.aus em::after { content: ' · ausverkauft'; }
.punkte-m { display: none; }
.tagauswahl { margin-top: 26px; }
.tagauswahl h3.titel { font: 800 1.375rem/1.1 'Schibsted Grotesk', sans-serif; padding-bottom: 12px; border-bottom: 1px solid var(--linie-stark); display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
.tagauswahl .tag { grid-template-columns: minmax(0, 1fr); padding-top: 18px; }
.tagauswahl .tag-datum { display: none; }

/* Leerzustand */
.leer { padding: 56px 0 40px; border-bottom: 1px solid var(--linie); display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 18px 40px; align-items: end; }
.leer[hidden] { display: none; }
.leer h3 { font: 800 clamp(1.75rem, 3vw, 2.5rem)/1.05 'Schibsted Grotesk', sans-serif; letter-spacing: -.015em; max-width: 22ch; }
.leer p { font-size: 1.0625rem; line-height: 1.5; color: var(--leise); margin-top: 10px; max-width: 52ch; }
.leer .wege { display: flex; gap: 8px; flex-wrap: wrap; }

/* Hinweis: Toast */
.meldung { position: fixed; left: 50%; bottom: 28px; z-index: 30; translate: -50% 16px; opacity: 0; visibility: hidden; display: grid; grid-template-columns: auto 1fr; gap: 2px 14px; align-items: center; width: min(460px, calc(100vw - 32px)); padding: 14px 18px; background: var(--tinte); color: var(--grund); transition: opacity 250ms var(--kurve), translate 250ms var(--kurve), visibility 0s linear 250ms; }
.meldung.an { opacity: 1; translate: -50% 0; visibility: visible; transition-delay: 0s; }
.meldung svg { width: 22px; height: 22px; grid-row: span 2; }
.meldung b { font: 700 .9375rem/1.3 'Schibsted Grotesk', sans-serif; }
.meldung small { font: 400 .75rem/1.35 'IBM Plex Mono', monospace; color: rgba(239, 237, 233, .72); }

/* ---------- Demo-Pult ---------- */
.pult { position: fixed; right: 16px; bottom: 16px; z-index: 20; display: grid; gap: 8px; padding: 12px 14px; background: rgba(239, 237, 233, .94); border: 1px solid var(--linie-stark); font: 500 .6875rem/1.4 'IBM Plex Mono', monospace; letter-spacing: .1em; text-transform: uppercase; color: var(--leise); max-width: 330px; }
.pult .zeile { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.pult button { font: inherit; letter-spacing: inherit; text-transform: inherit; height: 26px; padding: 0 9px; border: 1px solid var(--linie-stark); color: var(--tinte); }
.pult button[aria-pressed="true"] { background: var(--tinte); color: var(--grund); }
.pult b { color: var(--tinte); font-weight: 500; }

/* Gedachter Zeiger für die Vorführung */
.zeiger { position: fixed; left: 0; top: 0; z-index: 40; width: 22px; height: 22px; pointer-events: none; display: none; transition: transform 620ms var(--kurve), scale 150ms var(--kurve); }
.vorfuehrung .zeiger { display: block; }
.zeiger.drueckt { scale: .82; }

/* ---------- Reduzierte Bewegung ---------- */
.reduziert .flaeche, .reduziert .neu { animation: none; }
.reduziert .ansicht::before, .reduziert .ansicht label { transition: none; }
.reduziert .meldung { translate: -50% 0; transition: opacity 150ms linear, visibility 0s linear 150ms; }
.reduziert .meldung.an { transition-delay: 0s; }
.reduziert .zeiger { transition: none; }
.reduziert .knopf { transition: none; }

/* ---------- Touch ---------- */
@media (hover: none) {
  .rubrik:hover { background: none; }
  .rubrik[aria-pressed="true"]:hover { background: var(--farbe, var(--tinte)); }
  button.zelle:hover { background: none; }
  .knopf.voll:hover { background: var(--tinte); }
  .knopf.rand:hover { background: none; color: var(--tinte); }
  .pfeil:hover:not(:disabled) { background: none; color: var(--tinte); }
}

/* ---------- Schmal ---------- */
@media (max-width: 1360px) {
  .steuerung { grid-template-columns: auto 1fr; }
  .rubriken { grid-column: 1 / -1; grid-row: 2; }
  .rechts { justify-content: flex-end; }
}
@media (max-width: 1100px) {
  .termin { grid-template-columns: 6rem minmax(0, 1fr); }
  .aktionen { grid-column: 2; flex-direction: row; flex-wrap: wrap; min-width: 0; }
  .aktionen > * { flex: 1 1 11rem; }
}
@media (max-width: 760px) {
  .haus { height: 60px; }
  .haus nav { display: none; }
  .marke small { display: none; }
  .ticket { padding: 11px 14px; }
  .programm { padding-top: 26px; }
  .programm-kopf { grid-template-columns: 1fr; }
  h1 span { font-size: 1.125rem; line-height: 1.25; }
  .vorspann { font-size: 1.0625rem; }
  .steuerung { top: 60px; grid-template-columns: minmax(0, 1fr); gap: 12px; }
  .monatswahl { justify-content: space-between; }
  .monatswahl h2 { min-width: 0; font-size: 1.375rem; }
  .rubriken { grid-row: auto; flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; margin: 0 calc(var(--kante) * -1); padding: 0 var(--kante); }
  .rubriken::-webkit-scrollbar { display: none; }
  .rubrik { flex: none; height: 44px; }
  .rechts { justify-content: space-between; gap: 10px; }
  .treffer { text-align: left; min-width: 0; white-space: normal; font-size: .875rem; }
  .ansicht label { padding: 0 11px; }
  .tag.kompakt { gap: 4px; }
  .tag.kompakt .tag-datum .nr { font-size: 1.375rem; }
  .tag.kompakt .termin { grid-template-columns: auto minmax(0, 1fr); gap: 0 12px; }
  .tag.kompakt .zeit { display: block; }
  .tag.kompakt .aktionen { display: none; }
  .tag.kompakt .tag-datum .marker { display: inline; }
  .tag.kompakt h3 { white-space: normal; overflow: visible; }
  .ansicht label { height: 38px; }
  .tag { grid-template-columns: 1fr; gap: 14px; padding: 20px 0; }
  .tag-datum { grid-template-columns: auto auto 1fr; align-items: center; }
  .tag-datum .nr { font-size: 2.25rem; grid-row: auto; }
  .termin { grid-template-columns: 1fr; gap: 4px; }
  .zeit { display: flex; gap: 10px; align-items: baseline; padding: 0; }
  .aktionen { grid-column: auto; margin-top: 10px; }
  .aktionen > * { flex: 1 1 8rem; }
  /* Monat als Punkt-Übersicht */
  .wochentage span { padding-left: 0; text-align: center; font-size: .6875rem; letter-spacing: .06em; }
  .raster { grid-auto-rows: 58px; }
  .zelle { align-items: center; padding: 8px 2px; }
  .zelle .nr { justify-content: center; margin: 0; }
  .zelle.heute .nr::after { content: none; }
  .zelle .eintrag { display: none; }
  .punkte-m { display: flex; gap: 3px; justify-content: center; flex-wrap: wrap; }
  .punkte-m i { width: 7px; height: 7px; background: var(--farbe); }
  .leer { grid-template-columns: 1fr; padding-top: 36px; }
  .meldung { bottom: 16px; }
}

/* ---------- Vorschau 960 × 720: nur die Bühne ---------- */
.vorschau body { overflow: hidden; }
.vorschau .haus nav ul { gap: 20px; }
.vorschau .programm { padding-top: 22px; }
.vorschau .vorspann small { display: none; }
.vorschau h1 { font-size: 4.25rem; }
.vorschau .programm-kopf { padding-bottom: 18px; }
.vorschau .raster { grid-auto-rows: minmax(70px, auto); }
.vorschau .zelle { padding: 6px 7px; }
.vorschau .eintrag span { -webkit-line-clamp: 2; }
.vorschau .tag { padding: 16px 0; }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<header class="haus">
  <a class="marke" href="#programm" aria-label="Elbsalon, Startseite">
    <svg viewBox="0 0 42 26" aria-hidden="true"><path d="M3 25V4h6v21M33 25V4h6v21" fill="none" stroke="currentColor" stroke-width="3"/><path d="M9 9h24M9 14h24" stroke="currentColor" stroke-width="2"/><path d="M0 25h42" stroke="currentColor" stroke-width="2"/></svg>
    <span><b>Elbsalon</b><small class="label">Am Winterhafen · Magdeburg</small></span>
  </a>
  <nav aria-label="Hauptnavigation">
    <ul>
      <li><a href="#programm" aria-current="page">Programm</a></li>
      <li><a href="#programm">Ausstellungen</a></li>
      <li><a href="#programm">Besuch</a></li>
      <li><a href="#programm">Leichte Sprache</a></li>
    </ul>
  </nav>
  <a class="ticket" href="#programm">Tickets</a>
</header>

<main class="programm" id="programm" data-variante="programm">
  <div class="programm-kopf">
    <h1>Programm<span>Musik, Bühne, Kunst und Film im alten Kesselhaus</span></h1>
    <p class="vorspann">Alle Termine im Elbsalon auf einen Blick. Filtern Sie nach einer oder mehreren Rubriken und legen Sie Termine mit einem Klick in Ihren Kalender.
      <small>Abendkasse öffnet eine Stunde vor Beginn. Alle Räume sind stufenlos erreichbar.</small></p>
  </div>

  <div class="steuerung">
    <div class="monatswahl">
      <button class="pfeil" type="button" data-monat="-1" aria-label="Voriger Monat"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10 3 5 8l5 5" fill="none" stroke="currentColor" stroke-width="1.8"/></svg></button>
      <h2 id="monatsname" aria-live="polite">November 2026</h2>
      <button class="pfeil" type="button" data-monat="1" aria-label="Nächster Monat"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m6 3 5 5-5 5" fill="none" stroke="currentColor" stroke-width="1.8"/></svg></button>
    </div>
    <ul class="rubriken" aria-label="Nach Rubrik filtern"></ul>
    <div class="rechts">
      <p class="treffer" id="treffer" aria-live="polite"></p>
      <fieldset class="ansicht">
        <legend>Ansicht</legend>
        <label><input type="radio" name="ansicht" value="liste" checked><svg viewBox="0 0 15 15" aria-hidden="true"><path d="M1 3h13M1 7.5h13M1 12h13" stroke="currentColor" stroke-width="1.6"/></svg>Liste</label>
        <label><input type="radio" name="ansicht" value="monat"><svg viewBox="0 0 15 15" aria-hidden="true"><path d="M1.5 2.5h12v11h-12zM1.5 6h12M5.5 6v7.5M9.5 6v7.5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>Monat</label>
      </fieldset>
    </div>
  </div>
  <ul class="tagesleiste" aria-label="Tag wählen"></ul>

  <div class="ansichten">
    <section class="flaeche" id="liste" aria-label="Termine als Liste"><ol class="tage"></ol></section>
    <section class="flaeche" id="monat" aria-label="Termine als Monatsübersicht" hidden>
      <div class="wochentage" aria-hidden="true"><span>Mo</span><span>Di</span><span>Mi</span><span>Do</span><span>Fr</span><span>Sa</span><span>So</span></div>
      <ol class="raster"></ol>
      <div class="tagauswahl" aria-live="polite"></div>
    </section>
    <div class="leer" hidden>
      <div>
        <h3></h3>
        <p></p>
      </div>
      <div class="wege"></div>
    </div>
  </div>
</main>

<div class="meldung" role="status" aria-live="polite"><svg viewBox="0 0 22 22" aria-hidden="true"><rect x="2.5" y="4" width="17" height="15.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M2.5 8.5h17M7 2v4M15 2v4" stroke="currentColor" stroke-width="1.5"/><path d="m7.5 13.6 2.4 2.3 4.6-4.8" fill="none" stroke="currentColor" stroke-width="1.6"/></svg><b></b><small></small></div>

<div class="pult">
  <div class="zeile">Variante
    <button type="button" data-v="programm" aria-pressed="true">Liste + Monat</button>
    <button type="button" data-v="tage" aria-pressed="false">Tagesleiste</button>
  </div>
  <div>Kurve <b>0.215, 0.61, 0.355, 1</b> · UI <b>200</b> · Ansicht <b>320 ms</b></div>
  <div>Linien <b id="linienart">…</b> · Demo-Stand <b>Di 10.11.2026, 17:00</b></div>
</div>
<svg class="zeiger" data-zeiger viewBox="0 0 22 22" aria-hidden="true"><path d="M3 2l15 8.2-6.6 1.6L8.6 19z" fill="#0b0b0b" stroke="#efede9" stroke-width="1.5" stroke-linejoin="round"/></svg>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const $ = (s, el = document) => el.querySelector(s), $$ = (s, el = document) => [...el.querySelectorAll(s)];

  /* ---------- Daten ---------- */
  // Demo-Stand fest, damit „vergangen“ und „heute“ immer stimmen. Im Einsatz: const JETZT = new Date();
  const JETZT = { tag: '2026-11-10', zeit: '17:00' };
  const HAUS = 'Elbsalon, Am Winterhafen 3, 39104 Magdeburg';
  const RUBRIKEN = [
    { id: 'musik', name: 'Musik', farbe: 'var(--musik)' },
    { id: 'buehne', name: 'Bühne', farbe: 'var(--buehne)' },
    { id: 'kunst', name: 'Kunst', farbe: 'var(--kunst)' },
    { id: 'film', name: 'Film', farbe: 'var(--film)' },
  ];
  const R = Object.fromEntries(RUBRIKEN.map(r => [r.id, r]));
  // [id, tag, zeit, dauer min, rubrik, titel, unterzeile, ort, preis, status]  status: '' | 'aus' | 'knapp' | 'frei'
  const T = [
    ['salz-und-strom', '2026-11-01', '11:00', 120, 'kunst', 'Eröffnung: Salz und Strom', 'Fotografien vom Industriehafen 1952 bis heute, mit Gesprächen der Fotografinnen.', 'Halle', 'Eintritt frei', 'frei'],
    ['kurzfilmnacht-nov', '2026-11-04', '20:00', 110, 'film', 'Kurzfilmnacht Mitteldeutschland', 'Neun Filme aus Halle, Leipzig und Magdeburg, danach Gespräch an der Bar.', 'Kino', '6 €', ''],
    ['strombruecke-1', '2026-11-06', '19:30', 85, 'buehne', 'Unter der Strombrücke', 'Tanztheater der Kompanie Ostufer über Arbeit, Schichtwechsel und Wasser.', 'Saal', '18 € / ermäßigt 12 €', ''],
    ['nachtstuecke', '2026-11-07', '20:00', 90, 'musik', 'Kammerchor Ostelbien: Nachtstücke', 'Romantische Chormusik bei Kerzenlicht im großen Saal.', 'Saal', '22 €', 'aus'],
    ['fuehrung-salz', '2026-11-10', '19:00', 60, 'kunst', 'Kuratorinnenführung: Salz und Strom', 'Mit Henrike Ostwald durch die Ausstellung. Treffpunkt im Foyer.', 'Halle', '5 €', 'knapp'],
    ['trio-kanalhafen', '2026-11-12', '20:00', 100, 'musik', 'Jazz im Studio: Trio Kanalhafen', 'Kontrabass, Schlagzeug, Vibrafon. Eigene Stücke und zwei Lieder aus der Börde.', 'Studio', '16 €', ''],
    ['strombruecke-2', '2026-11-13', '19:30', 85, 'buehne', 'Unter der Strombrücke', 'Zweite Vorstellung. Einführung um 18:45 im Foyer.', 'Saal', '18 € / ermäßigt 12 €', 'aus'],
    ['kinderkino-wal', '2026-11-14', '15:00', 75, 'film', 'Kinderkino: Der Wal im Hafenbecken', 'Animationsfilm ab 6 Jahren, mit Kakao im Foyer.', 'Kino', '4 €', ''],
    ['klubnacht-boerde', '2026-11-14', '22:00', 300, 'musik', 'Klubnacht: Bördebass', 'Vier DJs, zwei Floors, offene Garderobe bis 4 Uhr.', 'Halle', '12 €', ''],
    ['druckwerkstatt', '2026-11-15', '11:00', 150, 'kunst', 'Familienwerkstatt: Drucken mit Fundstücken', 'Wir drucken mit Treibholz, Netzen und Schrauben vom Elbufer. Ab 5 Jahren.', 'Werkstatt', '6 €, Kinder frei', 'knapp'],
    ['ostmoderne', '2026-11-18', '19:00', 130, 'film', 'Filmgespräch: Ostmoderne in Farbe', 'Dokumentarfilm über Magdeburger Bauten der 1960er-Jahre, danach Podium.', 'Kino', '8 €', ''],
    ['brueckenwaerterin', '2026-11-19', '19:30', 90, 'buehne', 'Lesung: Die Brückenwärterin', 'Kerstin Hallwachs liest aus ihrem Roman über die Hubbrücke.', 'Studio', '10 €', ''],
    ['elbsaiten', '2026-11-20', '20:00', 80, 'musik', 'Werkstattkonzert Elbsaiten', 'Neue Musik für Streichquartett und Elektronik, drei Uraufführungen.', 'Saal', '14 € / ermäßigt 9 €', ''],
    ['boerde-bande', '2026-11-21', '19:30', 100, 'buehne', 'Improtheater: Die Börde-Bande', 'Das Publikum bestimmt Ort, Zeit und Ende. Jeden dritten Samstag.', 'Studio', '12 €', ''],
    ['doppel-bruecken', '2026-11-26', '19:30', 170, 'film', 'Doppelprogramm: Brücken', 'Zwei Filme über Grenzen, die man überquert. Pause mit Suppe.', 'Kino', '9 €', ''],
    ['lange-nacht-druck', '2026-11-27', '18:00', 300, 'kunst', 'Lange Nacht der Druckgrafik', 'Offene Pressen, Mappen zum Blättern, Drucke ab 20 € zum Mitnehmen.', 'Halle', 'Eintritt frei', 'frei'],
    ['novemberlieder', '2026-11-28', '20:00', 90, 'musik', 'Liederabend: Novemberlieder', 'Lieder von Schubert bis Eisler, am Flügel Jonas Brettschneider.', 'Saal', '20 €', 'aus'],
    ['elbbiber', '2026-11-29', '16:00', 50, 'buehne', 'Puppentheater: Der Elbbiber', 'Ein Biber baut einen Damm und lernt seine Nachbarn kennen. Ab 4 Jahren.', 'Studio', '7 €', 'knapp'],
    ['kurzfilmnacht-dez', '2026-12-02', '20:00', 110, 'film', 'Kurzfilmnacht: Winterrolle', 'Kurze Filme über Kälte, Licht und Heimkehr.', 'Kino', '6 €', ''],
    ['strombruecke-3', '2026-12-04', '19:30', 85, 'buehne', 'Unter der Strombrücke', 'Wiederaufnahme nach der Herbstpause.', 'Saal', '18 € / ermäßigt 12 €', ''],
    ['blaeser-rotehorn', '2026-12-05', '20:00', 90, 'musik', 'Bläserquintett Rotehorn', 'Reicha, Nielsen und eine neue Suite für Elbe und Hafen.', 'Saal', '18 €', ''],
    ['klubnacht-winter', '2026-12-11', '22:00', 300, 'musik', 'Klubnacht: Wintergarten', 'House und Disco unter Lichterketten.', 'Halle', '12 €', ''],
    ['kinderkino-schnee', '2026-12-12', '15:00', 70, 'film', 'Kinderkino: Die Schneeflockenfabrik', 'Animationsfilm ab 5 Jahren.', 'Kino', '4 €', ''],
    ['revue', '2026-12-17', '19:30', 120, 'buehne', 'Jahresrevue der Hausband', 'Lieder, Sketche und ein Rückblick auf das Jahr im Elbsalon.', 'Saal', '15 €', ''],
    ['bigband', '2026-12-19', '20:00', 120, 'musik', 'Jahresausklang: Elbsalon Big Band', 'Swing für 17 Musikerinnen und Musiker, Tanzfläche frei.', 'Halle', '19 €', 'aus'],
  ].map(([id, tag, zeit, dauer, rubrik, titel, unter, ort, preis, status]) => ({ id, tag, zeit, dauer, rubrik, titel, unter, ort, preis, status }));
  const MONATE = ['2026-11', '2026-12'];

  /* ---------- Datum ohne Zeitzonen-Fallen: Kalenderrechnung in UTC ---------- */
  const teile = s => s.split('-').map(Number);
  const utc = (tag, zeit = '00:00') => { const [j, m, d] = teile(tag), [h, mi] = zeit.split(':').map(Number); return new Date(Date.UTC(j, m - 1, d, h, mi)); };
  const fmt = (o) => new Intl.DateTimeFormat('de-DE', { timeZone: 'UTC', ...o });
  const fWochentag = fmt({ weekday: 'short' }), fLang = fmt({ weekday: 'long', day: 'numeric', month: 'long' }), fMonat = fmt({ month: 'long', year: 'numeric' }), fMonatKurz = fmt({ month: 'short' });
  const wt = d => fWochentag.format(d).replace('.', '');
  const ende = t => new Date(utc(t.tag, t.zeit).getTime() + t.dauer * 60000);
  const hm = d => String(d.getUTCHours()).padStart(2, '0') + ':' + String(d.getUTCMinutes()).padStart(2, '0');
  const jetzt = utc(JETZT.tag, JETZT.zeit);
  const vorbei = t => ende(t) <= jetzt || (t.tag < JETZT.tag);
  const tagVorbei = tag => tag < JETZT.tag;

  /* ---------- ICS (RFC 5545) ---------- */
  const enc = new TextEncoder();
  const icsText = s => String(s).replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
  function falte(zeile) {   // höchstens 75 Oktette je Zeile, UTF-8-Zeichen nie zerteilen
    let aus = '', laenge = 0;
    for (const z of zeile) {
      const b = enc.encode(z).length;
      if (laenge + b > 75) { aus += '\r\n '; laenge = 1; }
      aus += z; laenge += b;
    }
    return aus;
  }
  const lokal = d => d.toISOString().slice(0, 16).replace(/[-:]/g, '') + '00';   // Wandzeit Europe/Berlin
  const stempel = () => new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
  const VTIMEZONE = ['BEGIN:VTIMEZONE', 'TZID:Europe/Berlin',
    'BEGIN:DAYLIGHT', 'TZOFFSETFROM:+0100', 'TZOFFSETTO:+0200', 'TZNAME:CEST', 'DTSTART:19700329T020000', 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU', 'END:DAYLIGHT',
    'BEGIN:STANDARD', 'TZOFFSETFROM:+0200', 'TZOFFSETTO:+0100', 'TZNAME:CET', 'DTSTART:19701025T030000', 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU', 'END:STANDARD',
    'END:VTIMEZONE'];
  function ics(liste) {
    const z = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Elbsalon Magdeburg//Programm//DE', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', ...VTIMEZONE];
    liste.forEach(t => z.push('BEGIN:VEVENT',
      'UID:' + t.id + '-' + t.tag + '@elbsalon.example',
      'DTSTAMP:' + stempel(),
      'DTSTART;TZID=Europe/Berlin:' + lokal(utc(t.tag, t.zeit)),
      'DTEND;TZID=Europe/Berlin:' + lokal(ende(t)),
      'SUMMARY:' + icsText(t.titel),
      'LOCATION:' + icsText('Elbsalon, ' + t.ort + ', Am Winterhafen 3, 39104 Magdeburg'),
      'DESCRIPTION:' + icsText(t.unter + '\nRubrik: ' + R[t.rubrik].name + ' · Preis: ' + t.preis + (t.status === 'aus' ? '\nAusverkauft, Warteliste an der Abendkasse.' : '')),
      'CATEGORIES:' + icsText(R[t.rubrik].name),
      'URL:https://elbsalon.example/programm/' + t.id,
      'END:VEVENT'));
    z.push('END:VCALENDAR');
    return z.map(falte).join('\r\n') + '\r\n';
  }
  const meldung = $('.meldung');
  let meldungUhr;
  function melde(titel, info) {
    $('b', meldung).textContent = titel; $('small', meldung).textContent = info;
    meldung.classList.add('an'); clearTimeout(meldungUhr);
    meldungUhr = setTimeout(() => meldung.classList.remove('an'), 3200);
  }
  function herunterladen(liste, name, knopf) {
    const text = ics(liste);
    const blob = new Blob([text], { type: 'text/calendar;charset=utf-8' });
    if (!html.classList.contains('vorfuehrung')) {   // in der Vorführung kein echter Download
      const url = URL.createObjectURL(blob), a = document.createElement('a');
      a.href = url; a.download = name; document.body.append(a); a.click(); a.remove();
      setTimeout(() => URL.revokeObjectURL(url), 4000);
    }
    const kb = (blob.size / 1024).toLocaleString('de-DE', { maximumFractionDigits: 1 });
    melde(name, `${liste.length} ${liste.length === 1 ? 'Termin' : 'Termine'} · ${kb} KB · öffnet in Kalender, Outlook oder Google Kalender`);
    if (knopf) { knopf.classList.add('gespeichert'); $('.text', knopf).textContent = 'Im Kalender'; }
  }
  const dateiname = t => `elbsalon-${t.tag}-${t.id}.ics`;

  /* ---------- Zustand ---------- */
  const startV = new URLSearchParams(location.search).get('v');
  const zustand = { monat: MONATE[0], rubriken: new Set(), ansicht: 'liste', tag: JETZT.tag, variante: startV === 'tage' ? 'tage' : 'programm' };
  const passt = t => t.tag.startsWith(zustand.monat) && (!zustand.rubriken.size || zustand.rubriken.has(t.rubrik));
  const imMonat = () => T.filter(passt).sort((a, b) => (a.tag + a.zeit).localeCompare(b.tag + b.zeit));

  /* ---------- Bausteine ---------- */
  const ikon = {
    ticket: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M1.5 4.5h13v2a1.5 1.5 0 0 0 0 3v2h-13v-2a1.5 1.5 0 0 0 0-3z" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M10 5v6" stroke="currentColor" stroke-width="1.2" stroke-dasharray="1.5 1.5"/></svg>',
    kal: '<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="1.5" y="3" width="13" height="11.5" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M1.5 6.5h13M5 1v3.5M11 1v3.5M8 8.5v4M6 10.5h4" stroke="currentColor" stroke-width="1.4"/></svg>',
    uhr: '<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6.3" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M8 4.5V8l2.5 1.6" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>',
  };
  function termin(t, i) {
    const r = R[t.rubrik], v = vorbei(t), dLang = fLang.format(utc(t.tag));
    const li = document.createElement('li');
    li.className = 'termin' + (t.status === 'aus' ? ' aus' : '');
    li.style.setProperty('--farbe', r.farbe);
    let aktion;
    if (v) aktion = `<span class="vorbei-zeile">Vorbei</span>`;
    else if (t.status === 'aus') aktion = `<span class="status-aus">Ausverkauft</span><a class="knopf rand" href="#warteliste" data-warteliste>${ikon.uhr}Auf die Warteliste</a>`;
    else if (t.status === 'frei') aktion = `<span class="status-aus" style="background:none;border-style:solid;border-color:var(--linie)">Ohne Anmeldung</span>`;
    else aktion = `<a class="knopf voll" href="#tickets" data-ticket>${ikon.ticket}Tickets · ${t.preis.split(/ \/|,/)[0]}</a>`;
    if (!v) aktion += `<button type="button" class="knopf rand" data-ics="${t.id}" aria-label="${t.titel}, ${dLang}, in den Kalender legen">${ikon.kal}<span class="text">In Kalender</span></button>`;
    li.innerHTML = `
      <p class="zeit">${t.zeit}<small>bis ${hm(ende(t))}</small></p>
      <div>
        <span class="chip">${r.name}</span>
        <h3>${t.titel}</h3>
        <p class="unter">${t.unter}</p>
        <p class="meta"><span>${t.ort}</span><span>${t.dauer} Min.</span><span>${t.preis}</span>${t.status === 'knapp' && !v ? '<span class="knapp">Wenige Plätze</span>' : ''}</p>
      </div>
      <div class="aktionen">${aktion}</div>`;
    return li;
  }
  function tagBlock(tag, liste, i) {
    const d = utc(tag), li = document.createElement('li');
    const ist = tag === JETZT.tag, war = tagVorbei(tag) || liste.every(vorbei);
    li.className = 'tag neu' + (war ? ' vorbei kompakt' : '');
    li.style.setProperty('--i', i);
    li.innerHTML = `<div class="tag-datum"><span class="nr">${d.getUTCDate()}</span><span class="wt">${wt(d)} · ${fMonatKurz.format(d).replace('.', '')}</span>${ist ? '<span class="marker">Heute</span>' : war ? '<span class="marker leise">Vorbei</span>' : ''}</div><ol class="termine"></ol>`;
    liste.forEach((t, k) => $('.termine', li).append(termin(t, k)));
    return li;
  }
  const gruppiert = liste => liste.reduce((m, t) => ((m[t.tag] ||= []).push(t), m), {});

  /* ---------- Rubrik-Filter ---------- */
  const rubrikListe = $('.rubriken');
  function baueRubriken() {
    const imM = T.filter(t => t.tag.startsWith(zustand.monat));
    [{ id: 'alle', name: 'Alle' }, ...RUBRIKEN].forEach(r => {
      const n = r.id === 'alle' ? imM.length : imM.filter(t => t.rubrik === r.id).length;
      const an = r.id === 'alle' ? !zustand.rubriken.size : zustand.rubriken.has(r.id);
      let b = $(`.rubrik[data-r="${r.id}"]`, rubrikListe);
      if (!b) {
        const li = document.createElement('li');
        li.innerHTML = `<button type="button" class="rubrik" data-r="${r.id}" ${r.farbe ? `style="--farbe:${r.farbe}"` : ''}>${r.farbe ? '<i aria-hidden="true"></i>' : ''}${r.name} <span class="zahl"></span></button>`;
        rubrikListe.append(li); b = li.firstChild;
      }
      b.setAttribute('aria-pressed', an);
      const z = $('.zahl', b); z.textContent = n; z.setAttribute('aria-label', `${n} ${n === 1 ? 'Termin' : 'Termine'} im Monat`);
    });
  }
  rubrikListe.addEventListener('click', e => {
    const b = e.target.closest('.rubrik'); if (!b) return;
    const id = b.dataset.r;
    if (id === 'alle') zustand.rubriken.clear();
    else zustand.rubriken.has(id) ? zustand.rubriken.delete(id) : zustand.rubriken.add(id);
    if (zustand.rubriken.size === RUBRIKEN.length) zustand.rubriken.clear();
    zeichne();
  });

  /* ---------- Zeichnen ---------- */
  const listeEl = $('#liste'), monatEl = $('#monat'), leerEl = $('.leer');
  function zeichne() {
    const liste = imMonat(), mo = utc(zustand.monat + '-01');
    $('#monatsname').textContent = fMonat.format(mo);
    $$('[data-monat]').forEach(b => { const i = MONATE.indexOf(zustand.monat) + +b.dataset.monat; b.disabled = i < 0 || i >= MONATE.length; });
    baueRubriken();
    const kommend = liste.filter(t => !vorbei(t)).length;
    $('#treffer').innerHTML = `<b>${liste.length}</b> ${liste.length === 1 ? 'Termin' : 'Termine'}${kommend !== liste.length && liste.length ? `, <b>${kommend}</b> kommend` : ''}`;
    leerEl.hidden = liste.length > 0;
    if (!liste.length) zeigeLeer();
    if (zustand.variante === 'tage') { zeichneTage(liste); }
    else {
      listeEl.hidden = zustand.ansicht !== 'liste' || !liste.length;
      monatEl.hidden = zustand.ansicht !== 'monat';
      if (zustand.ansicht === 'liste') zeichneListe(liste); else zeichneMonat(liste);
    }
  }
  function zeichneListe(liste) {
    const ol = $('.tage', listeEl); ol.innerHTML = '';
    Object.entries(gruppiert(liste)).forEach(([tag, l], i) => ol.append(tagBlock(tag, l, i)));
  }
  function zeichneMonat(liste) {
    const [j, m] = teile(zustand.monat), erster = new Date(Date.UTC(j, m - 1, 1));
    const versatz = (erster.getUTCDay() + 6) % 7, tageImMonat = new Date(Date.UTC(j, m, 0)).getUTCDate();
    const zellen = Math.ceil((versatz + tageImMonat) / 7) * 7, g = gruppiert(liste), ol = $('.raster', monatEl);
    if (!g[zustand.tag]) zustand.tag = Object.keys(g).find(t => t >= JETZT.tag) || Object.keys(g)[0] || zustand.tag;
    ol.innerHTML = '';
    for (let k = 0; k < zellen; k++) {
      const d = new Date(Date.UTC(j, m - 1, 1 - versatz + k)), tag = d.toISOString().slice(0, 10), li = document.createElement('li');
      const fremd = d.getUTCMonth() !== m - 1, l = fremd ? [] : (g[tag] || []);
      const kl = 'zelle' + (fremd ? ' fremd' : '') + (tagVorbei(tag) ? ' vorbei' : '') + (tag === JETZT.tag ? ' heute' : '');
      const nr = `<span class="nr"><b>${d.getUTCDate()}</b></span>`;
      if (l.length) {
        li.innerHTML = `<button type="button" class="${kl}" data-tag="${tag}" aria-pressed="${tag === zustand.tag}" aria-label="${fLang.format(d)}: ${l.length} ${l.length === 1 ? 'Termin' : 'Termine'}${tag === JETZT.tag ? ', heute' : ''}">${nr}
          ${l.map(t => `<span class="eintrag${t.status === 'aus' ? ' aus' : ''}" style="--farbe:${R[t.rubrik].farbe}"><span><em>${t.zeit} · ${R[t.rubrik].name}</em>${t.titel}</span></span>`).join('')}
          <span class="punkte-m" aria-hidden="true">${l.map(t => `<i style="--farbe:${R[t.rubrik].farbe}"></i>`).join('')}</span></button>`;
      } else li.innerHTML = `<div class="${kl}" ${fremd ? 'aria-hidden="true"' : ''}>${nr}</div>`;
      ol.append(li);
    }
    zeigeTag(g);
  }
  function zeigeTag(g = gruppiert(imMonat())) {
    const box = $('.tagauswahl'), l = g[zustand.tag];
    $$('.raster button.zelle').forEach(b => b.setAttribute('aria-pressed', b.dataset.tag === zustand.tag));
    if (!l) { box.innerHTML = ''; return; }
    box.innerHTML = `<h3 class="titel">${fLang.format(utc(zustand.tag))}<span class="label">${l.length} ${l.length === 1 ? 'Termin' : 'Termine'}</span></h3><ol class="tage"></ol>`;
    $('.tage', box).append(tagBlock(zustand.tag, l, 0));
  }
  $('.raster').addEventListener('click', e => { const b = e.target.closest('button.zelle'); if (!b) return; zustand.tag = b.dataset.tag; zeigeTag(); });

  /* Variante Tagesleiste */
  function zeichneTage(liste) {
    const g = gruppiert(liste), tage = Object.keys(g), leiste = $('.tagesleiste');
    monatEl.hidden = true; listeEl.hidden = !liste.length;
    if (!g[zustand.tag]) zustand.tag = tage.find(t => t >= JETZT.tag) || tage[0] || zustand.tag;
    leiste.innerHTML = tage.map(tag => { const d = utc(tag);
      return `<li><button type="button" class="${tagVorbei(tag) ? 'vorbei' : ''}${tag === JETZT.tag ? ' heute' : ''}" data-tag="${tag}" aria-pressed="${tag === zustand.tag}" aria-label="${fLang.format(d)}, ${g[tag].length} ${g[tag].length === 1 ? 'Termin' : 'Termine'}"><span class="wt">${tag === JETZT.tag ? 'Heute' : wt(d)}</span><span class="nr">${d.getUTCDate()}</span><span class="punkte">${g[tag].map(t => `<i style="--farbe:${R[t.rubrik].farbe}"></i>`).join('')}</span></button></li>`; }).join('');
    const ol = $('.tage', listeEl); ol.innerHTML = '';
    if (g[zustand.tag]) ol.append(tagBlock(zustand.tag, g[zustand.tag], 0));
    const akt = $('[aria-pressed="true"]', leiste);
    if (akt) leiste.scrollTo({ left: akt.parentElement.offsetLeft - leiste.offsetLeft - 8, behavior: reduziert() ? 'auto' : 'smooth' });
  }
  $('.tagesleiste').addEventListener('click', e => { const b = e.target.closest('button'); if (!b) return; zustand.tag = b.dataset.tag; zeichne(); });

  /* Leerzustand mit Ausweg */
  function zeigeLeer() {
    const namen = [...zustand.rubriken].map(id => R[id].name).join(' und ');
    const mo = fMonat.format(utc(zustand.monat + '-01')).split(' ')[0];
    const anders = MONATE.find(mm => mm !== zustand.monat && T.some(t => t.tag.startsWith(mm) && zustand.rubriken.has(t.rubrik)));
    $('h3', leerEl).textContent = `Im ${mo} gibt es keine Termine in ${namen}.`;
    $('p', leerEl).textContent = 'Das Programm für Januar erscheint am 1. Dezember. Bis dahin finden Sie in den anderen Rubriken oder im Nachbarmonat etwas.';
    const w = $('.wege', leerEl); w.innerHTML = '';
    if (anders) w.innerHTML += `<button type="button" class="knopf voll" data-gehe="${anders}">${namen} im ${fMonat.format(utc(anders + '-01')).split(' ')[0]}</button>`;
    w.innerHTML += `<button type="button" class="knopf rand" data-zuruecksetzen>Alle Rubriken zeigen</button>`;
  }
  leerEl.addEventListener('click', e => {
    if (e.target.closest('[data-zuruecksetzen]')) { zustand.rubriken.clear(); zeichne(); }
    const g = e.target.closest('[data-gehe]'); if (g) { zustand.monat = g.dataset.gehe; zeichne(); }
  });

  /* Monat wechseln, Ansicht wechseln */
  $$('[data-monat]').forEach(b => b.addEventListener('click', () => {
    const i = MONATE.indexOf(zustand.monat) + +b.dataset.monat; if (i < 0 || i >= MONATE.length) return;
    zustand.monat = MONATE[i]; zustand.tag = zustand.monat === JETZT.tag.slice(0, 7) ? JETZT.tag : zustand.monat + '-01'; zeichne();
  }));
  $$('input[name="ansicht"]').forEach(r => r.addEventListener('change', () => { zustand.ansicht = r.value; zeichne(); }));

  /* Aktionen in den Terminen */
  document.addEventListener('click', e => {
    const k = e.target.closest('[data-ics]');
    if (k) { const t = T.find(x => x.id === k.dataset.ics); herunterladen([t], dateiname(t), k); return; }
    if (e.target.closest('[data-ticket]')) { e.preventDefault(); melde('Ticketshop', 'Im Einsatz öffnet hier das Ticketsystem des Hauses (in der Demo nicht verbunden).'); }
    if (e.target.closest('[data-warteliste]')) { e.preventDefault(); melde('Warteliste', 'Wir melden uns per E-Mail, sobald Karten zurückkommen (in der Demo nicht verbunden).'); }
  });

  /* Variante */
  function setzeVariante(v, schreiben) {
    zustand.variante = v === 'tage' ? 'tage' : 'programm';
    $('.programm').dataset.variante = zustand.variante;
    $$('.pult [data-v]').forEach(b => b.setAttribute('aria-pressed', b.dataset.v === zustand.variante));
    if (schreiben) { const q = new URLSearchParams(location.search); q.set('v', zustand.variante); history.replaceState(null, '', '?' + q.toString().replace(/=(?=&|$)/g, '')); }
    zeichne();
  }
  $$('.pult [data-v]').forEach(b => b.addEventListener('click', () => setzeVariante(b.dataset.v, true)));
  $('#linienart').textContent = CSS.supports('row-rule', '1px solid') ? 'Gap Decorations' : 'Fallback (Lücken)';

  /* ---------- Vorführung: gedachter Zeiger ---------- */
  const zeiger = $('[data-zeiger]');
  let lauf = 0, angehalten = false;
  const warte = async (ms, id) => { let rest = ms; while (rest > 0) { await new Promise(r => setTimeout(r, 50)); if (id !== lauf) throw 0; if (!angehalten) rest -= 50; } };
  async function geheZu(el, id) {
    if (typeof el === 'string') el = $(el);
    if (!el) return null;
    const r = el.getBoundingClientRect();
    if (r.top < 140 || r.bottom > innerHeight - 20) { el.scrollIntoView({ block: 'center', behavior: reduziert() ? 'auto' : 'smooth' }); await warte(500, id); }
    const q = el.getBoundingClientRect();
    zeiger.style.transform = `translate(${q.left + q.width * .5}px, ${q.top + q.height * .55}px)`;
    await warte(reduziert() ? 200 : 700, id);
    return el;
  }
  async function klick(el, id) {
    el = await geheZu(el, id); if (!el) return;
    zeiger.classList.add('drueckt'); el.classList.add('drueckt');
    await warte(150, id);
    zeiger.classList.remove('drueckt'); el.classList.remove('drueckt');
    (el.matches('label') ? $('input', el) : el).click();
  }
  function grundzustand() {
    zustand.monat = MONATE[0]; zustand.rubriken.clear(); zustand.tag = JETZT.tag; zustand.ansicht = 'liste';
    $('input[value="liste"]').checked = true; scrollTo(0, 0);
    zeichne();
  }
  async function szene(id) {
    grundzustand();
    zeiger.style.transform = `translate(${innerWidth * .72}px, ${innerHeight * .3}px)`;
    await warte(350, id);
    await klick('.rubrik[data-r="musik"]', id); await warte(900, id);
    if (zustand.variante === 'tage') {
      await klick('.tagesleiste button[data-tag="2026-11-14"]', id); await warte(900, id);
      await klick('[data-ics="klubnacht-boerde"]', id); await warte(1500, id);
      await klick('.rubrik[data-r="film"]', id); await warte(800, id);
      await klick('.tagesleiste button[data-tag="2026-11-18"]', id); await warte(1400, id);
      await klick('[data-monat="1"]', id); await warte(1500, id);
    } else {
      await klick('[data-ics="trio-kanalhafen"]', id); await warte(1500, id);
      await klick('.ansicht label:nth-of-type(2)', id); await warte(900, id);
      await klick('.rubrik[data-r="film"]', id); await warte(800, id);
      await klick('.raster button[data-tag="2026-11-14"]', id); await warte(1500, id);
      await klick('.rubrik[data-r="musik"]', id); await warte(300, id);
      await klick('.rubrik[data-r="film"]', id); await warte(300, id);
      await klick('.rubrik[data-r="kunst"]', id); await warte(500, id);
      await klick('[data-monat="1"]', id); await warte(1700, id);
    }
    await warte(600, id);
  }
  function vorfuehren() {
    const id = ++lauf; angehalten = false;
    html.classList.add('vorfuehrung');
    (async () => { try { while (id === lauf) await szene(id); } catch (e) { /* abgebrochen */ } })();
  }
  function pause() { angehalten = true; }
  function weiter() { if (html.classList.contains('vorfuehrung')) angehalten = false; else vorfuehren(); }
  function neustart() { lauf++; angehalten = false; html.classList.remove('vorfuehrung'); meldung.classList.remove('an'); grundzustand(); }

  setzeVariante(zustand.variante, false);

})();
</script>
</body>
</html>