v0.1 — Event Areas & Lineup Slots
Branch: feat/event-areas-and-lineup
Date: 02/05/2026
Type: feat (normalized tables, vendor wizard, admin edit panel)
Context
Events in Flow can be complex: a nightclub may run multiple rooms simultaneously (techno room, commercial room, rooftop) each with its own artists and schedule. Before this feature, the only way to represent this was via a freeform events.lineup JSONB column — unstructured, unsearchable, and invisible to the web dashboards.
This feature introduces two normalized tables to model multi-area events and their timed programme slots, fully optional so simple single-room events remain unchanged.
Backward compatibility
The events.lineup JSONB column is preserved untouched. Seven seed events had existing JSONB lineup data that the mobile app reads. No migration of this data was performed — the column co-exists with the new tables.
Database Changes
Migration file: supabase/migrations/20260502000001_event_areas_and_lineup_slots.sql
Applied: directly to production via Supabase Management API.
event_areas
Represents a physical zone within an event (stage, room, rooftop, etc.).
CREATE TABLE public.event_areas (
id uuid PRIMARY KEY DEFAULT extensions.uuid_generate_v4(),
event_id uuid NOT NULL REFERENCES public.events(id) ON DELETE CASCADE,
name text NOT NULL CHECK (length(trim(name)) > 0),
description text,
capacity integer CHECK (capacity IS NULL OR capacity > 0),
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);| Column | Notes |
|---|---|
event_id | Cascades on delete — areas are removed when the event is deleted |
name | Trimmed non-empty check enforced at DB level |
capacity | Optional — per-area capacity cap |
sort_order | Display order; managed by the persist function |
event_lineup_slots
Represents a single timed entry in the programme (DJ set, live act, talk, etc.).
CREATE TABLE public.event_lineup_slots (
id uuid PRIMARY KEY DEFAULT extensions.uuid_generate_v4(),
event_id uuid NOT NULL REFERENCES public.events(id) ON DELETE CASCADE,
area_id uuid REFERENCES public.event_areas(id) ON DELETE SET NULL,
title text NOT NULL CHECK (length(trim(title)) > 0),
artist text,
description text,
start_time timestamptz NOT NULL,
end_time timestamptz,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT event_lineup_slots_end_after_start
CHECK (end_time IS NULL OR end_time > start_time)
);| Column | Notes |
|---|---|
area_id | Optional FK — ON DELETE SET NULL so deleting an area doesn’t remove its slots |
end_time | Optional; DB-level check enforces end_time > start_time |
sort_order | Display order; managed by the persist function |
Indexes
CREATE INDEX idx_event_areas_event_id ON public.event_areas(event_id);
CREATE INDEX idx_event_areas_sort ON public.event_areas(event_id, sort_order);
CREATE INDEX idx_event_lineup_event_id ON public.event_lineup_slots(event_id);
CREATE INDEX idx_event_lineup_area_id ON public.event_lineup_slots(area_id);
CREATE INDEX idx_event_lineup_sort ON public.event_lineup_slots(event_id, sort_order);
CREATE INDEX idx_event_lineup_start_time ON public.event_lineup_slots(event_id, start_time);RLS Policies
Both tables follow the same pattern: public read, write restricted to the event organizer or an admin.
| Table | Policy | Subjects | Operations |
|---|---|---|---|
event_areas | Everyone can view | public | SELECT |
event_areas | Organizers manage own event areas | auth.uid() = organizer_id OR is_admin() | ALL |
event_lineup_slots | Everyone can view | public | SELECT |
event_lineup_slots | Organizers manage own event lineup slots | auth.uid() = organizer_id OR is_admin() | ALL |
The USING clause joins through events to check organizer_id:
EXISTS (
SELECT 1 FROM events e
WHERE e.id = event_areas.event_id
AND (e.organizer_id = auth.uid() OR is_admin())
)Updated_at trigger
Both tables have an update_updated_at() trigger that keeps updated_at current on every row update.
Client Architecture
tempId pattern
The client never works with DB-generated UUIDs directly during editing. Instead, every area and slot gets a tempId (crypto.randomUUID()) on the client side. This solves the circular dependency problem: a slot must reference an area’s DB id, but the area doesn’t have a DB id until after it’s been inserted.
Resolution flow:
Client state After insert
────────────── ──────────────────────
area.tempId ────────→ tempToDbId.get(tempId) → DB uuid
slot.areaTempId ────→ tempToDbId.get(areaTempId) → area's DB uuid
When loading from DB, the library creates a fresh tempId and builds a dbToTemp map to re-attach slots to their areas.
Persist strategy: delete-and-reinsert
Rather than diffing and syncing, persistEventAreasAndLineup deletes all existing rows for the event and reinserts from the current client state. This is safe because:
- Record counts are low (<50 areas/slots per event)
- It avoids complex conflict-resolution logic
ON DELETE SET NULLonarea_idmeans slots are not automatically deleted when areas are deleted (but since we delete slots first, this is moot)
Order of operations:
1. DELETE event_lineup_slots WHERE event_id = ? (slots first — FK dependency)
2. DELETE event_areas WHERE event_id = ?
3. INSERT clean areas → capture returned IDs → build tempId→dbId map
4. INSERT slots with resolved area_id FKs
Files
New files
| File | Description |
|---|---|
web/components/vendor/EventAreasEditor.tsx | Reusable editor UI for areas (add/edit/remove) |
web/components/vendor/EventLineupEditor.tsx | Reusable editor UI for lineup slots with area select |
web/lib/eventAreasLineup.ts | loadEventAreasAndLineup + persistEventAreasAndLineup |
supabase/migrations/20260502000001_event_areas_and_lineup_slots.sql | DB migration |
Modified files
| File | Change |
|---|---|
web/app/vendor/(dashboard)/events/page.tsx | Wizard extended to 6 steps; edit dialog extended to 4 steps |
web/app/admin/(dashboard)/events/[id]/page.tsx | Two new sections (areas + lineup) with dedicated save button |
Component API
EventAreasEditor
import EventAreasEditor, { type EventArea, newArea } from '@/components/vendor/EventAreasEditor'
<EventAreasEditor
areas={areas} // EventArea[]
onChange={setAreas} // (next: EventArea[]) => void
/>EventArea type:
type EventArea = {
id?: string // present when loaded from DB
tempId: string // always present, client-generated
name: string
description?: string
capacity?: number | null
}EventLineupEditor
import EventLineupEditor, { type EventLineupSlot, newSlot } from '@/components/vendor/EventLineupEditor'
<EventLineupEditor
slots={slots} // EventLineupSlot[]
areas={areas} // EventArea[] — used to populate the area dropdown
onChange={setSlots} // (next: EventLineupSlot[]) => void
eventStart={event.start_date} // optional — pre-fills start_time on new slots
/>EventLineupSlot type:
type EventLineupSlot = {
id?: string // present when loaded from DB
tempId: string // always present, client-generated
areaTempId?: string | null
title: string
artist?: string
description?: string
start_time: string // datetime-local format (YYYY-MM-DDTHH:mm)
end_time?: string
}loadEventAreasAndLineup
const { areas, slots } = await loadEventAreasAndLineup(eventId: string)Fetches both tables in parallel, builds the dbId → tempId map, and returns typed arrays ready for component state.
persistEventAreasAndLineup
await persistEventAreasAndLineup(eventId: string, areas: EventArea[], slots: EventLineupSlot[])Delete-and-reinsert. Filters out areas with empty names and slots with empty titles or missing start_time before inserting.
Vendor Wizard — Creation Flow
The event creation wizard was extended from 4 to 6 steps:
| Step | Label | Content |
|---|---|---|
| 0 | Dettagli | Title, description, dates, price, status |
| 1 | Biglietti | Ticket types |
| 2 | Immagine | Cover image URL |
| 3 | Aree | EventAreasEditor |
| 4 | Lineup | EventLineupEditor |
| 5 | Riepilogo | Preview + confirm |
Areas and lineup are fully optional — a vendor can skip steps 3 and 4 entirely.
On submit, createEvent inserts the event row first, retrieves the generated id, then calls persistEventAreasAndLineup only if areas or slots are non-empty.
Implementation note: The creation flow uses a plain
asyncfunction (createEvent) withuseStatefor loading state, notuseMutation. This was necessary due to a Turbopack/HMR bug whereuseMutation’smutationFnwas silently not called when invoked from inside a Headless UIDialog.
Vendor Wizard — Edit Flow
The edit dialog was extended to 4 steps:
| Step | Label | Content |
|---|---|---|
| 0 | Dettagli | Core event fields |
| 1 | Aree | EventAreasEditor |
| 2 | Lineup | EventLineupEditor |
| 3 | Anteprima | Preview before saving |
When a vendor opens the edit dialog (startEdit()), the component immediately calls loadEventAreasAndLineup to populate areas and slots from DB.
On save, saveEdit updates the event row and then calls persistEventAreasAndLineup for areas and lineup.
Implementation note: Same as creation —
saveEditis a plainasyncfunction withuseState(false)for the saving state. TheuseMutationpattern was replaced after the same Turbopack/HMR bug was identified causing the save button to silently do nothing.
Admin Edit Panel
The admin event detail page (/admin/events/[id]) was extended with two new sections at the bottom of the page, below the main event form:
- Aree —
EventAreasEditorbound toareasstate - Lineup / Programma —
EventLineupEditorbound toslotsstate
A dedicated “Salva aree e lineup” button calls saveProgramme(), which is intentionally separate from the main “Salva Modifiche” button. This avoids accidental overwrites: an admin editing only the event title should not trigger a full lineup persist.
async function saveProgramme() {
await persistEventAreasAndLineup(id, areas, slots)
const fresh = await loadEventAreasAndLineup(id)
setAreas(fresh.areas)
setSlots(fresh.slots)
}After saving, the state is refreshed from DB to reflect the newly generated DB IDs.
Known Limitations
| Limitation | Notes |
|---|---|
| Mobile app does not render new tables | Mobile reads events.lineup JSONB only. Rendering event_areas and event_lineup_slots on mobile is a planned future task. |
| No drag-to-reorder in editor | sort_order is currently set by array index at persist time. A drag handle UI was considered but deferred. |
| No conflict detection on overlapping times | If two slots in the same area overlap, no warning is shown. Only the end_time > start_time constraint per slot is enforced. |
| Delete-and-reinsert loses history | If a slot’s DB id is referenced externally in the future (e.g., analytics), reinsert will generate a new UUID. Not a concern at current scale. |