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()
);
ColumnNotes
event_idCascades on delete — areas are removed when the event is deleted
nameTrimmed non-empty check enforced at DB level
capacityOptional — per-area capacity cap
sort_orderDisplay 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)
);
ColumnNotes
area_idOptional FK — ON DELETE SET NULL so deleting an area doesn’t remove its slots
end_timeOptional; DB-level check enforces end_time > start_time
sort_orderDisplay 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.

TablePolicySubjectsOperations
event_areasEveryone can viewpublicSELECT
event_areasOrganizers manage own event areasauth.uid() = organizer_id OR is_admin()ALL
event_lineup_slotsEveryone can viewpublicSELECT
event_lineup_slotsOrganizers manage own event lineup slotsauth.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 NULL on area_id means 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

FileDescription
web/components/vendor/EventAreasEditor.tsxReusable editor UI for areas (add/edit/remove)
web/components/vendor/EventLineupEditor.tsxReusable editor UI for lineup slots with area select
web/lib/eventAreasLineup.tsloadEventAreasAndLineup + persistEventAreasAndLineup
supabase/migrations/20260502000001_event_areas_and_lineup_slots.sqlDB migration

Modified files

FileChange
web/app/vendor/(dashboard)/events/page.tsxWizard extended to 6 steps; edit dialog extended to 4 steps
web/app/admin/(dashboard)/events/[id]/page.tsxTwo 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:

StepLabelContent
0DettagliTitle, description, dates, price, status
1BigliettiTicket types
2ImmagineCover image URL
3AreeEventAreasEditor
4LineupEventLineupEditor
5RiepilogoPreview + 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 async function (createEvent) with useState for loading state, not useMutation. This was necessary due to a Turbopack/HMR bug where useMutation’s mutationFn was silently not called when invoked from inside a Headless UI Dialog.


Vendor Wizard — Edit Flow

The edit dialog was extended to 4 steps:

StepLabelContent
0DettagliCore event fields
1AreeEventAreasEditor
2LineupEventLineupEditor
3AnteprimaPreview 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 — saveEdit is a plain async function with useState(false) for the saving state. The useMutation pattern 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:

  • AreeEventAreasEditor bound to areas state
  • Lineup / ProgrammaEventLineupEditor bound to slots state

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

LimitationNotes
Mobile app does not render new tablesMobile reads events.lineup JSONB only. Rendering event_areas and event_lineup_slots on mobile is a planned future task.
No drag-to-reorder in editorsort_order is currently set by array index at persist time. A drag handle UI was considered but deferred.
No conflict detection on overlapping timesIf 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 historyIf 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.