Skip to content

Commit 0fb97dc

Browse files
Merge pull request #9 from nyxCore-Systems/feat/timetable-categories-lineup
feat(events): timetable content categories + slot-driven line-up section
2 parents 43e8c00 + 9d830a6 commit 0fb97dc

22 files changed

Lines changed: 1336 additions & 149 deletions

File tree

docs/superpowers/plans/2026-07-03-timetable-categories-lineup.md

Lines changed: 745 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
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`, sonstFoto 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+
### AdminFilter + 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 OrderEingangsreihenfolge 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.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- Rename the slot-category column and remap legacy role values to content categories.
2+
ALTER TABLE "Appearance" RENAME COLUMN "role" TO "category";
3+
UPDATE "Appearance" SET "category" = 'musik' WHERE "category" IN ('headliner', 'support', 'guest');
4+
-- 'break' bleibt unverändert.
5+
ALTER TABLE "Appearance" ALTER COLUMN "category" SET DEFAULT 'musik';

prisma/schema.prisma

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -729,7 +729,7 @@ model Appearance {
729729
stageId String
730730
artistId String?
731731
title String?
732-
role String @default("support")
732+
category String @default("musik")
733733
startTime DateTime
734734
endTime DateTime?
735735
note String?

prisma/seed.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -250,26 +250,26 @@ async function main() {
250250
bySlug('jed-thomas-band'), bySlug('rovar'), bySlug('nanny-goats'), bySlug('the-klaxon'),
251251
])
252252

253-
const eventAppearances: { stageId: string; artistId?: string; title?: string; role: string; startTime: Date }[] = [
253+
const eventAppearances: { stageId: string; artistId?: string; title?: string; category: string; startTime: Date }[] = [
254254
// Freitag 07.08.
255-
{ stageId: haupt.id, artistId: nanny?.id, role: 'support', startTime: new Date('2026-08-07T18:00:00+02:00') },
256-
{ stageId: haupt.id, artistId: rovar?.id, role: 'support', startTime: new Date('2026-08-07T19:00:00+02:00') },
257-
{ stageId: zelt.id, artistId: klaxon?.id, role: 'support', startTime: new Date('2026-08-07T19:00:00+02:00') },
258-
{ stageId: haupt.id, artistId: jed?.id, role: 'support', startTime: new Date('2026-08-07T20:30:00+02:00') },
259-
{ stageId: zelt.id, artistId: killa?.id, role: 'guest', startTime: new Date('2026-08-07T20:30:00+02:00') },
260-
{ stageId: haupt.id, artistId: risager?.id, role: 'headliner', startTime: new Date('2026-08-07T22:00:00+02:00') },
255+
{ stageId: haupt.id, artistId: nanny?.id, category: 'musik', startTime: new Date('2026-08-07T18:00:00+02:00') },
256+
{ stageId: haupt.id, artistId: rovar?.id, category: 'musik', startTime: new Date('2026-08-07T19:00:00+02:00') },
257+
{ stageId: zelt.id, artistId: klaxon?.id, category: 'musik', startTime: new Date('2026-08-07T19:00:00+02:00') },
258+
{ stageId: haupt.id, artistId: jed?.id, category: 'musik', startTime: new Date('2026-08-07T20:30:00+02:00') },
259+
{ stageId: zelt.id, artistId: killa?.id, category: 'musik', startTime: new Date('2026-08-07T20:30:00+02:00') },
260+
{ stageId: haupt.id, artistId: risager?.id, category: 'musik', startTime: new Date('2026-08-07T22:00:00+02:00') },
261261
// Samstag 08.08.
262-
{ stageId: haupt.id, title: 'Soundcheck & Begrüßung', role: 'break', startTime: new Date('2026-08-08T17:30:00+02:00') },
263-
{ stageId: haupt.id, artistId: lebron?.id, role: 'support', startTime: new Date('2026-08-08T19:00:00+02:00') },
264-
{ stageId: zelt.id, artistId: nanny?.id, role: 'support', startTime: new Date('2026-08-08T19:00:00+02:00') },
265-
{ stageId: haupt.id, artistId: killa?.id, role: 'headliner', startTime: new Date('2026-08-08T21:30:00+02:00') },
262+
{ stageId: haupt.id, title: 'Soundcheck & Begrüßung', category: 'break', startTime: new Date('2026-08-08T17:30:00+02:00') },
263+
{ stageId: haupt.id, artistId: lebron?.id, category: 'musik', startTime: new Date('2026-08-08T19:00:00+02:00') },
264+
{ stageId: zelt.id, artistId: nanny?.id, category: 'musik', startTime: new Date('2026-08-08T19:00:00+02:00') },
265+
{ stageId: haupt.id, artistId: killa?.id, category: 'musik', startTime: new Date('2026-08-08T21:30:00+02:00') },
266266
]
267267
for (const [i, a] of eventAppearances.entries()) {
268268
await prisma.appearance.create({
269269
data: {
270270
eventId: festival.id, stageId: a.stageId,
271271
artistId: a.artistId ?? null, title: a.title ?? null,
272-
role: a.role, startTime: a.startTime, sortOrder: i,
272+
category: a.category, startTime: a.startTime, sortOrder: i,
273273
},
274274
})
275275
}

src/app/(public)/events/[slug]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export default async function EventDetailPage({ params }: Props) {
107107
<h2 className="mb-4 font-display text-2xl font-bold text-brand-text">Line-up</h2>
108108
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
109109
{uniqueLineup.map((a) => (
110-
<ArtistCard key={a.artist!.slug} artist={{ slug: a.artist!.slug, name: a.artist!.name, isFeatured: a.role === 'headliner' }} />
110+
<ArtistCard key={a.artist!.slug} artist={{ slug: a.artist!.slug, name: a.artist!.name, isFeatured: a.artist?.isFeatured ?? false }} />
111111
))}
112112
</div>
113113
</section>

0 commit comments

Comments
 (0)