The Events list is the most heavily-used Lookout surface. It’s a paginated, filter-driven table of every event in the partner’s portfolio with status badges, demand benchmarks, and a click-through to the detail page.

What you’ll build

  • A filter toolbar (search, date range, city, venue, status, simulated, campaigns, include archived).
  • An infinite-scroll or paginated table.
  • A KPI strip above the table.
  • Row-level actions: flag, archive, open detail.

Prerequisites

  • An authenticated user.
  • X-Preferred-Partner-Id set.

The call chain

On mount:
#MethodPathPurpose
1GET/events/statisticsKPI strip header.
2GET/events/?page=1&limit=50&...First page of events.
On filter change:
MethodPathNotes
GET/events/?...Re-fetch with new filters. Cancel the previous request with a CancelToken (the reference webapp uses axios.CancelToken).
On scroll-near-bottom:
MethodPathNotes
GET/events/?page=N+1&...Append to existing list.
On row open:
MethodPathNotes
GET/events/{eid}Drawer-or-detail event data.
On flag/archive (row menu):
MethodPathNotes
PUT/events/{eid}/setup_frontend_status{ frontend_status: 'FLAGGED' | 'DEFAULT' | 'HIDDEN' }. Returns 204.
PUT[cr_api] /events/last_updated_on_fe/{eid}{ status: 'RUNNING' | 'CANCELLED' }. Both must succeed.
On Location filter open:
MethodPathNotes
GET/events/venues?city=...For the venue dropdown grouped by city.

Filter parameters

All sent to GET /events/. Empty values must be omitted, not sent as empty strings.
ParamTypeNotes
pageint (1-based)Default 1.
limitintDefault 50.
qstringFree-text search.
since / untilYYYY-MM-DDValidate with moment before sending.
statusstringBackend lifecycle status.
frontend_statusarrayRepeated query param. Values: DEFAULT, FLAGGED, HIDDEN.
marketing_campaign_statusstring
simulatedboolFilter sim events.
flagged_to_topboolPin flagged rows.
cityarrayRepeated query param.
vharrayVenue/hall ids.
include_archivedbool
campaignsboolOnly events with campaigns.
exclude_productsboolLookout sends true; Wave sends false/absent.
descendingboolSort order.
Array params use the repeat style:
?city=Berlin&city=Munich&frontend_status=DEFAULT&frontend_status=FLAGGED
Not city[]= and not city=Berlin,Munich. The backend rejects both.

Response envelope

{
  "total": 423,
  "limit": 50,
  "page":  1,
  "pages": 9,
  "nextPage": 2,
  "prevPage": null,
  "items": [
    {
      "id":               "evt_abc123",
      "title":            "Concert at Olympiahalle",
      "start_date_time":  "2026-06-12T18:30:00Z",
      "benchmark":        { "prediction": { "value": 0.78, "benchmark": 0.65 }, "state": "above" },
      "frontend_status":  "DEFAULT",
      "hall_name":        "Main Hall",
      "venue_name":       "Olympiahalle",
      "city":             "München",
      "capacity":         12000,
      "has_active_campaigns":             true,
      "client_campaign_status":           "RUNNING",
      "campaign_optimisation_status":     "ON",
      "ad_campaign_optimisation_status":  "ON",
      "campaign_event_series":            ["evt_abc124", "evt_abc125"],
      "campaign_status":                  "RUNNING",
      "campaign_stop_time":               "2026-06-12T18:30:00Z",
      "category":                         "concert",
      "special_ad_category":              null
    }
  ]
}
The reference webapp normalises this with formatApiEvent into a flatter shape — { benchmark, now, state } — used by the row component. Don’t normalise too early; the detail page reads the raw benchmark again.

Reference implementation

import qs from "qs";

export async function fetchEvents(filters: EventFilters, signal?: AbortSignal) {
  // Strip empty values to avoid polluting the URL
  const params = Object.fromEntries(
    Object.entries(filters).filter(([_, v]) =>
      v != null && !(Array.isArray(v) && v.length === 0) && v !== ""
    )
  );

  const url = `/events/?${qs.stringify(params, { arrayFormat: "repeat" })}`;
  return fd.get<EventList>(url, { signal });
}
Cancel previous requests on filter change:
const controllerRef = useRef<AbortController | null>(null);

function refetch(filters: EventFilters) {
  controllerRef.current?.abort();
  controllerRef.current = new AbortController();

  fetchEvents(filters, controllerRef.current.signal)
    .then(setEvents)
    .catch(e => {
      if (e.name === "AbortError") return;   // expected on filter change
      throw e;
    });
}

Flag / archive — the two-step dance

export async function updateEventStatus(
  eid: string,
  frontendStatus: "FLAGGED" | "DEFAULT" | "HIDDEN"
) {
  // Step 1 — main API
  await fd.put(`/events/${eid}/setup_frontend_status`, { frontend_status: frontendStatus });

  // Step 2 — CR API (different host!)
  const crStatus = frontendStatus === "HIDDEN" ? "CANCELLED" : "RUNNING";
  await fdCr.put(`/events/last_updated_on_fe/${eid}`, { status: crStatus });
}
If step 2 fails after step 1 succeeds, the data is inconsistent across the two hosts. The reference webapp surfaces an error in this case — it does not roll back step 1. Mirror that, or retry step 2 from a background job.

Gotchas

/events/ and /events/flagged/ both keep their trailing slash.
The reference webapp distinguishes “page 1 load” (replace) from “page N+1 load” (append) so already-rendered rows stay visible during pagination. Replicate or your UI flickers on every scroll-load.
A cancelled request throws AbortError (or axios Cancel). Don’t surface those to the user — they’re expected on every filter change.
The reference webapp validates since / until with moment before sending; invalid strings get passed through unchanged. Validate server-side too, but reject early in your UI.
state is one of above, on_track, below. The reference webapp uses it to colour-code the benchmark cell; it also auto-detects whether value is a fraction (0-1) or a percentage on each call (peak < 1.5 ? scale ×100 : ×1).