|
| 1 | +# Timetable-Kategorien + auto-generierte Lineup-Sektion — Design |
| 2 | + |
| 3 | +**Datum:** 2026-07-03 |
| 4 | +**Status:** Design (zur Review) |
| 5 | + |
| 6 | +## Ziel |
| 7 | + |
| 8 | +Zwei gekoppelte Features: |
| 9 | + |
| 10 | +1. **Timetable-Kategorien:** Die rollenbasierten Slot-Kategorien (`headliner · support · guest · break`) werden durch inhaltsbasierte ersetzt: **`musik · film · performance · kinder · break`**. Damit lassen sich Programmpunkte nach Typ klassifizieren, statt nach Bühnenrolle. |
| 11 | +2. **Lineup-Sektion generiert sich aus Timetable-Slots:** Die Homepage-Sektion „Line-up" zieht ihren Inhalt künftig aus den Timetable-Slots des Featured-Events, gefiltert nach den neuen Kategorien (Mehrfachauswahl). Alle Slots werden gleichberechtigt (gleich große Karten) dargestellt; im Sektions-Editor lässt sich die Reihenfolge per Drag & Drop manuell festlegen (Standard: nach Tag + Uhrzeit). |
| 12 | + |
| 13 | +## Hintergrund / Ist-Zustand |
| 14 | + |
| 15 | +- Die Slot-Kategorie ist heute die Spalte **`Appearance.role`** (String, NOT NULL). Erlaubte Werte + Default `support` in `src/lib/event-validation.ts` (`ALLOWED_ROLES`, `sanitizeAppearance`). |
| 16 | +- `role` steuert mehr als nur das Label: |
| 17 | + - `src/lib/noir-home.ts`: `ROLE_TYPE`-Map (Anzeige-Label) + `highlight: role === 'headliner'` (Timetable-Hervorhebung). |
| 18 | + - `src/components/events/EventTimetable.tsx`: **★-Headliner**-Markierung + Fußnote. |
| 19 | + - `src/app/(public)/events/[slug]/page.tsx:110`: `isFeatured: a.role === 'headliner'` an `ArtistCard`. |
| 20 | + - `src/lib/events.ts`: `ROLE_RANK` sortiert `getFeaturedEventLineup` (Headliner zuerst). |
| 21 | +- Die **aktuelle Lineup-Sektion ist künstlerbasiert, nicht slotbasiert:** `NoirLineupSection` → `getFeaturedEventLineup()` liefert **Artists**, verlinkt auf `/kuenstler/…`, „Headliner"-Tag kommt aus `Artist.isFeatured` (nicht aus `role`). Erste 2 Acts = XL-Karten, Rest = MD-Karten. |
| 22 | +- `Appearance` hat **kein Bildfeld** (`artistId`, `title`, `role`, `note`, `startTime`, `stageId`). |
| 23 | + |
| 24 | +## Getroffene Entscheidungen |
| 25 | + |
| 26 | +- **Bildquelle für Nicht-Musik-Slots (Q1):** *Alles als Künstler pflegen.* Filme/Performance/Kinder-Acts werden als `Artist`-Datensätze angelegt; das Lineup-Bild ist `artist.heroImage`. **Kein Slot-Bildfeld, keine Artist-Schema-Änderung.** Slots ohne Künstler rendern eine text-only Karte mit „Foto folgt"-Platzhalter. |
| 27 | +- **Headliner-Hervorhebung (Q2):** *Ganz entfernen.* Kein ★/Highlight mehr im Timetable — alle Slots gleichwertig. Betonung im Lineup erfolgt ausschließlich über die manuelle Sortierung. |
| 28 | +- **Spalten-Umbenennung:** `Appearance.role` → **`Appearance.category`** (saubere, datenerhaltende `RENAME COLUMN`-Migration, hand-geschrieben, damit Prisma nicht drop+add macht). Alle `ap.role`/`role:`-Queries werden zu `category`. |
| 29 | + |
| 30 | +## Datenmodell |
| 31 | + |
| 32 | +### Prisma / Migration |
| 33 | + |
| 34 | +`prisma/schema.prisma` — im `Appearance`-Model Feld umbenennen: |
| 35 | + |
| 36 | +```prisma |
| 37 | +category String // slot content category: musik | film | performance | kinder | break |
| 38 | +``` |
| 39 | + |
| 40 | +Migration `prisma/migrations/20260703120000_rename_appearance_role_to_category/migration.sql` (hand-geschrieben, datenerhaltend): |
| 41 | + |
| 42 | +```sql |
| 43 | +-- Rename the slot-category column and remap legacy role values to content categories. |
| 44 | +ALTER TABLE "Appearance" RENAME COLUMN "role" TO "category"; |
| 45 | +UPDATE "Appearance" SET "category" = 'musik' WHERE "category" IN ('headliner', 'support', 'guest'); |
| 46 | +-- 'break' bleibt unverändert. |
| 47 | +``` |
| 48 | + |
| 49 | +> Prisma erkennt Renames nicht automatisch (würde drop+add erzeugen und Daten verlieren). Deshalb **Migration von Hand** schreiben und **nicht** `prisma migrate dev` den Diff neu generieren lassen. Auf der Live-DB läuft sie via `prisma migrate deploy` im Deploy-Job. Nach der Schema-Änderung lokal `npx prisma generate` **vor** `tsc`. |
| 50 | +
|
| 51 | +### Validierung (`src/lib/event-validation.ts`) |
| 52 | + |
| 53 | +```ts |
| 54 | +export const ALLOWED_SLOT_CATEGORIES = ['musik', 'film', 'performance', 'kinder', 'break'] as const |
| 55 | +export const DEFAULT_SLOT_CATEGORY = 'musik' |
| 56 | +``` |
| 57 | + |
| 58 | +`AppearanceInput.role` → `AppearanceInput.category`. `sanitizeAppearance` liest `r.category`, validiert gegen `ALLOWED_SLOT_CATEGORIES`, Fallback `DEFAULT_SLOT_CATEGORY`: |
| 59 | + |
| 60 | +```ts |
| 61 | +const catRaw = String(r.category || DEFAULT_SLOT_CATEGORY).toLowerCase().trim() |
| 62 | +const category = (ALLOWED_SLOT_CATEGORIES as readonly string[]).includes(catRaw) ? catRaw : DEFAULT_SLOT_CATEGORY |
| 63 | +``` |
| 64 | + |
| 65 | +### Kategorie-Labels (`src/lib/lineup.ts`) |
| 66 | + |
| 67 | +```ts |
| 68 | +export const CATEGORY_LABELS: Record<string, string> = { |
| 69 | + musik: 'Musik', film: 'Film', performance: 'Performance', kinder: 'Kinder', break: 'Break', |
| 70 | +} |
| 71 | +``` |
| 72 | + |
| 73 | +## Feature 1 — Timetable-Kategorien |
| 74 | + |
| 75 | +### Admin-Builder (`src/components/admin/events/TimetableBuilder.tsx`) |
| 76 | +- `const ROLES = […]` → `const CATEGORIES = ['musik','film','performance','kinder','break']`; Dropdown zeigt `CATEGORY_LABELS[c]`. |
| 77 | +- `Row.role` → `Row.category`; `toRow` mappt `a.category`. |
| 78 | +- Neuer Slot (`add`) sendet `category: 'musik'` statt `role: 'support'`. |
| 79 | +- `persist` sendet `category: row.category`. |
| 80 | + |
| 81 | +### API (`.../appearances/route.ts`, `.../appearances/[appId]/route.ts`) |
| 82 | +- `role: clean.role` → `category: clean.category` in `data:`. |
| 83 | + |
| 84 | +### Anzeige-Bereinigung (Headliner raus) |
| 85 | +- `src/lib/noir-home.ts`: `ROLE_TYPE` → nutzt `CATEGORY_LABELS` (`ap.category`); `highlight`-Feld entfällt (immer `false`/nicht gesetzt); `ap.role` → `ap.category`. |
| 86 | +- `src/components/events/EventTimetable.tsx`: ★-Logik (`isHeadliner`, `star`) + Fußnotentext „★ = Headliner" entfernen. |
| 87 | +- `src/app/(public)/events/[slug]/page.tsx:110`: `isFeatured: a.role === 'headliner'` → `isFeatured: a.artist?.isFeatured ?? false` (echtes Künstler-Flag). Dazu in `EventWithRelations`-Include (`src/lib/events.ts`) den Artist-`select` um `isFeatured: true` erweitern. |
| 88 | +- `src/lib/events.ts`: `ROLE_RANK` + der `.sort((a,b)=>ROLE_RANK…)`-Schritt in `getFeaturedEventLineup` entfallen; stattdessen Sortierung nach `startTime` (bereits durch `orderBy`), Dedup bleibt. |
| 89 | + |
| 90 | +### Seed (`prisma/seed.ts`) |
| 91 | +- `role:`-Felder der `eventAppearances` → `category:` mit neuen Werten (`headliner`/`support` → `musik`, `guest` → `musik`). |
| 92 | + |
| 93 | +## Feature 2 — Lineup-Sektion aus Slots |
| 94 | + |
| 95 | +### Datenschicht (`src/lib/lineup.ts`, neu) |
| 96 | + |
| 97 | +```ts |
| 98 | +export interface LineupSlot { |
| 99 | + appearanceId: string |
| 100 | + category: string |
| 101 | + categoryLabel: string |
| 102 | + name: string // artist.name ?? title ?? '—' |
| 103 | + image: string | null // artist.heroImage |
| 104 | + slug: string | null // artist.slug (Link-Ziel) oder null |
| 105 | + genres: string[] |
| 106 | + origin: string | null |
| 107 | + excerpt: string | null |
| 108 | + meta: string // "Fr · 22:00 · Hauptbühne" |
| 109 | +} |
| 110 | + |
| 111 | +export const LINEUP_DEFAULT_CATEGORIES = ['musik', 'film', 'performance', 'kinder'] // alle außer break |
| 112 | +``` |
| 113 | + |
| 114 | +**Reine, testbare Funktionen:** |
| 115 | + |
| 116 | +```ts |
| 117 | +// Validiert rohe Kategorien gegen ALLOWED_SLOT_CATEGORIES; leer/ungültig → LINEUP_DEFAULT_CATEGORIES. |
| 118 | +export function resolveLineupCategories(raw: unknown): string[] |
| 119 | + |
| 120 | +// slots kommen in (Tag, Uhrzeit)-Reihenfolge. savedOrder = appearanceIds. |
| 121 | +// Ergebnis: zuerst die Slots, deren id in savedOrder steht (in savedOrder-Reihenfolge), |
| 122 | +// danach die restlichen Slots in ihrer Eingangsreihenfolge. Nicht mehr existierende |
| 123 | +// ids in savedOrder werden ignoriert. |
| 124 | +export function orderSlots(slots: LineupSlot[], savedOrder: string[]): LineupSlot[] |
| 125 | +``` |
| 126 | + |
| 127 | +**Server-Funktion:** |
| 128 | + |
| 129 | +```ts |
| 130 | +export async function getLineupSlots(opts: { categories?: string[]; order?: string[] }): Promise<LineupSlot[]> |
| 131 | +``` |
| 132 | +- Featured-Event ermitteln (wie `getFeaturedEventLineup`: `isPublished && isActive`, `orderBy isFeatured desc, startDate asc`). |
| 133 | +- Appearances des Events laden, `category IN resolveLineupCategories(opts.categories)`, inkl. `artist` (`slug,name,heroImage,genres,origin,excerpt`) + `stage`, `orderBy startTime asc`. |
| 134 | +- Auf `LineupSlot[]` mappen, `meta` bauen (`Wochentag · Uhrzeit · Bühne`, Europe/Berlin). |
| 135 | +- `orderSlots(slots, opts.order ?? [])` anwenden. |
| 136 | +- **Keine Dedup nach Künstler** — ein Künstler mit zwei Slots erscheint zweimal (slotbasiert). |
| 137 | + |
| 138 | +### Public-Render (`src/components/noir/sections/NoirLineupSection.tsx`) |
| 139 | +- Signatur: `{ title?, subtitle?, content? }` mit `content = { categories?: string[]; order?: string[] } | null`. |
| 140 | +- `const slots = await getLineupSlots({ categories: content?.categories, order: content?.order })`. |
| 141 | +- Bei `slots.length === 0` → `null`. |
| 142 | +- Flaches Grid **gleich großer** Karten (kein XL/MD-Split). Pro Karte: Bild (`slot.image`, sonst „Foto folgt"), Name, Kategorie-Tag (`slot.categoryLabel`), Meta-Zeile. Link auf `/kuenstler/${slot.slug}` nur wenn `slot.slug` gesetzt, sonst nicht-verlinkte Karte. |
| 143 | +- Heading bleibt editierbar (`title`/`subtitle` mit Defaults). |
| 144 | + |
| 145 | +### Section-Plumbing (`src/components/noir/sections/NoirElement.tsx`) |
| 146 | +- `case 'noir_lineup': return <NoirLineupSection title={title} subtitle={subtitle} content={content} />` (content durchreichen). |
| 147 | + |
| 148 | +### Admin — Filter + Drag & Drop (`src/app/admin/sections/page.tsx`) |
| 149 | +Für `formType === 'noir_lineup'`: |
| 150 | +- **Kategorie-Checkboxen** (Musik/Film/Performance/Kinder/Break) → `content.categories: string[]`. |
| 151 | +- **Sortierbare Vorschau** der gefilterten Slots (dnd-kit, bereits Dependency): lädt Slots über die neue API, rendert `orderSlots(apiSlots, content.order)`, Drag & Drop aktualisiert `content.order` (Array der appearanceIds). Speichern schreibt `content = { categories, order }` in die Sektion. |
| 152 | + |
| 153 | +### Admin-API (`src/app/api/admin/lineup/preview/route.ts`, neu) |
| 154 | +- `GET ?categories=musik,film` — Auth via `getToken` (eingeloggt genügt). |
| 155 | +- Antwort `{ slots: LineupSlot[] }` in (Tag, Uhrzeit)-Reihenfolge (ohne manuelle Order; der Client wendet `orderSlots` mit seinem State-`order` an). |
| 156 | +- Nutzt `getLineupSlots({ categories, order: [] })` bzw. eine geteilte interne Query. |
| 157 | + |
| 158 | +## Tests |
| 159 | + |
| 160 | +- `src/lib/__tests__/event-validation.test.ts` — anpassen: Import `ALLOWED_SLOT_CATEGORIES`; Test-Inputs `role:` → `category:`; Assertions auf neue Kategorien; Default → `musik`; ungültig → `musik`. |
| 161 | +- `src/lib/__tests__/lineup.test.ts` (neu): |
| 162 | + - `resolveLineupCategories`: leer/undefined → `LINEUP_DEFAULT_CATEGORIES`; nur gültige Werte durchgelassen; unbekannte gefiltert. |
| 163 | + - `orderSlots`: gespeicherte Order wird angewendet; neue Slots (nicht in Order) hinten in Eingangsreihenfolge; gelöschte ids in Order ignoriert; leere Order → Eingangsreihenfolge unverändert. |
| 164 | + |
| 165 | +## Betroffene Dateien |
| 166 | + |
| 167 | +**Ändern:** |
| 168 | +- `prisma/schema.prisma` (Feld `role`→`category`) |
| 169 | +- `src/lib/event-validation.ts` (Konstanten, `sanitizeAppearance`, Typ) |
| 170 | +- `src/components/admin/events/TimetableBuilder.tsx` |
| 171 | +- `src/app/api/admin/events/[id]/appearances/route.ts` |
| 172 | +- `src/app/api/admin/events/[id]/appearances/[appId]/route.ts` |
| 173 | +- `src/lib/noir-home.ts` |
| 174 | +- `src/components/events/EventTimetable.tsx` |
| 175 | +- `src/app/(public)/events/[slug]/page.tsx` |
| 176 | +- `src/lib/events.ts` (`ROLE_RANK` raus, Include um `isFeatured` erweitern) |
| 177 | +- `src/components/noir/sections/NoirLineupSection.tsx` |
| 178 | +- `src/components/noir/sections/NoirElement.tsx` |
| 179 | +- `src/app/admin/sections/page.tsx` |
| 180 | +- `prisma/seed.ts` |
| 181 | +- `src/lib/__tests__/event-validation.test.ts` |
| 182 | + |
| 183 | +**Neu:** |
| 184 | +- `prisma/migrations/20260703120000_rename_appearance_role_to_category/migration.sql` |
| 185 | +- `src/lib/lineup.ts` |
| 186 | +- `src/app/api/admin/lineup/preview/route.ts` |
| 187 | +- `src/lib/__tests__/lineup.test.ts` |
| 188 | + |
| 189 | +## Global Constraints |
| 190 | + |
| 191 | +- **Gates:** `npx prisma generate` (nach Schema-Änderung) → `npx tsc --noEmit` → `npm test` → `npm run build`. CI führt **kein** Lint aus. |
| 192 | +- **UI-Copy:** Deutsch. |
| 193 | +- **Migration:** datenerhaltend (`RENAME COLUMN`), hand-geschrieben; läuft live via `prisma migrate deploy`. |
| 194 | +- **Git:** Dateien einzeln nach Namen stagen (kein `git add -A`); `.codex/`, `.memory/…`, `AGENTS.md` unangetastet lassen. Commit-Messages enden mit `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`. Deploy = push nach `main`. |
| 195 | +- **Lokale `.env` DATABASE_URL ist veraltet** — keine Skripte lokal gegen die Live-DB laufen lassen. |
| 196 | + |
| 197 | +## Out of Scope |
| 198 | + |
| 199 | +- Kein Slot-Bildfeld / keine Artist-Schema-Änderung (Q1: Pflege als Artist). |
| 200 | +- Kein Umbau des Timetable-Layouts über das Entfernen der Headliner-Emphase hinaus. |
| 201 | +- Hero-„Acts"-Zähler bleibt künstlerbasiert (`getFeaturedEventLineup`). |
| 202 | +- Keine Dedup der Lineup-Karten nach Künstler. |
0 commit comments