# Building a Radio Cult station website

You are helping build the pages of a Radio Cult station website. The Radio Cult API is the station's CMS: the schedule, shows, artist profiles, and play history all come from it, so there is no separate CMS to wire up. Read every page's data from the API with the publishable key.

The read-only building blocks this skill covers:

- **Schedule**: weekly grids, programme guides, upcoming-shows lists.
- **Now playing**: the live track and current show (handled by the companion player skill).
- **Artists**: an artist directory and per-artist profile pages with their upcoming shows.
- **Recently played**: a "last played tracks" feed.

Before writing code, check the live API reference for up-to-date endpoint paths and response shapes: https://www.radiocult.fm/docs/api

## What you need from the user

1. **Station ID**: found on the API settings page at https://app.radiocult.fm/settings/cms/api
2. **Publishable (public) API key**: starts with `pk_`, from the same settings page.

**Security rule (non-negotiable):** only the publishable key may appear in client-side code. Never use a secret key (starts with `sk_`) in a website, app bundle, or anything delivered to browsers.

The base URL for all API requests is `https://api.radiocult.fm`. Authenticate every request with the `x-api-key` header.

## The schedule endpoint

```
GET /api/station/:stationId/schedule?startDate=<iso>&endDate=<iso>
```

`startDate` and `endDate` are required ISO timestamps. Always include an explicit UTC offset (for example `2026-07-14T00:00:00Z`) so the range is unambiguous. The response is `{ success: true, schedules: Event[] }`, sorted by start time.

### Use `expand` when (and only when) you display artist or tag details

By default each Event only carries `artistIds` and `tagIds`. Add `expand` when the page renders artist or tag information, and each Event is hydrated in the same response:

- `expand=artist` adds `event.artists`: the full Artist objects (name, slug, logo artwork, socials, genres) alongside `artistIds`.
- `expand=tags` adds `event.tags`: the full Tag objects alongside `tagIds`.

Combine them (`expand=artist,tags`) or use just one, depending on what you show.

The rule is to match the fetch to the render:

- **Showing artist names or artwork?** Use `expand=artist`. Without it you would fetch each artist separately, one extra request per artist per event (the N+1 problem): slow pages and wasted rate limit. Never loop over `artistIds` making per-artist calls when `expand` does it in one request.
- **Showing tag labels?** Add `tags` to the expand list for the same reason.
- **A minimal grid of just show titles and times?** Skip `expand` entirely. Hydrating artists and tags you never render is wasted payload and slower responses. Fetch only what the view needs.

## Timezones (the other big gotcha)

- `startDateUtc` and `endDateUtc` on each Event are **UTC** timestamps. Never render them raw.
- Each Event also carries `timezone`: the timezone the event was created in, which is usually the station's local timezone.
- Decide with the user whether to display times in the station's timezone (typical for a station site) or the visitor's local timezone, and convert explicitly, for example with `Intl.DateTimeFormat` and its `timeZone` option or a date library.
- When building a weekly grid, compute day boundaries in the display timezone, not in UTC, or late-night shows land on the wrong day.

## Rendering events

- **Recurring shows**: the range endpoint returns each occurrence as its own Event, so no recurrence maths is needed. `isRecurring` tells you it belongs to a series.
- **Descriptions are TipTap JSON**, not plain text or HTML. `event.description` (and `artist.description`) are structured `JSONContent`. Render them with a TipTap/ProseMirror renderer, or walk the node tree and map paragraphs, text, and links to your markup. Do not dump them raw into the page.
- **Artwork**: artist `logo` is a map of pre-resized URLs (`'256x256'`, `'512x512'`, etc.). Pick the size that fits the layout and fall back, for example `logo?.['256x256'] ?? logo?.default`.
- **"On now" highlighting**: an event is live when the current time is between `startDateUtc` and `endDateUtc`. For a realtime now-playing display, use the companion Radio Cult custom player skill, which covers WebSocket metadata.

Example: fetch a week and group it by day in the station's timezone. This grid renders artist names and artwork, so it expands `artist`; drop `expand` if your grid only shows titles and times:

```js
const fetchWeekSchedule = async ({ stationId, publicKey, weekStart }) => {
  const startDate = weekStart.toISOString();
  const endDate = new Date(
    weekStart.getTime() + 7 * 24 * 60 * 60 * 1000
  ).toISOString();

  const params = new URLSearchParams({
    startDate,
    endDate,
    expand: 'artist',
  });
  const response = await fetch(
    `https://api.radiocult.fm/api/station/${stationId}/schedule?${params}`,
    { headers: { 'x-api-key': publicKey } }
  );
  const { schedules } = await response.json();
  return schedules;
};

const groupEventsByDay = (events, timeZone) => {
  const dayFormatter = new Intl.DateTimeFormat('en-GB', {
    timeZone,
    weekday: 'long',
    day: 'numeric',
    month: 'long',
  });

  return events.reduce((days, event) => {
    const day = dayFormatter.format(new Date(event.startDateUtc));
    (days[day] ??= []).push(event);
    return days;
  }, {});
};
```

## Artist pages

- `GET /api/station/:stationId/artists` lists all artists (name, `slug`, logo, socials, genres, country).
- `GET /api/station/:stationId/artists/:artistId` fetches one artist by ID; there is also a lookup by `slug`, which makes clean URLs like `/artists/dj-example`.
- `GET /api/station/:stationId/artists/:artistId/schedule?startDate=<iso>&endDate=<iso>` returns that artist's events in the range: ideal for "upcoming shows" on a profile page.
- Do not render `shareableLinkId` on a public site. It grants edit access to the artist profile.

## Now playing

For a live "on air now" banner or a now-playing widget, do not build it from the schedule. Use the companion **Radio Cult custom player skill**, which covers the realtime Socket.IO metadata feed and the audio element. The schedule tells you what is _scheduled_; the player metadata tells you what is _actually playing right now_ (including the current track title and artwork).

## Recently played

For a "last played" or "recently played tracks" feed:

```
GET /api/station/:stationId/streaming/history/latest-results?limit=<number>
```

- `limit` is optional (1 to 100, default 5). The response is `{ success: true, data: LastPlayed[] }`, sorted by `playoutStart` descending (most recent first).
- Each `LastPlayed` has `playoutStart` (a UTC ISO timestamp, so convert it like schedule times), `title`, `artist` (nullable), `album` (nullable), and `artwork` (a nullable map of pre-resized URLs, `artwork?.['256x256'] ?? artwork?.default`).
- This is play history, not the live track. For what is playing _right now_, use the player skill's realtime metadata instead.
- Poll sparingly if you refresh it (a track lasts minutes), or just fetch once on load. Never tight-loop this endpoint.

## Performance

- Fetch the visible range only (a week or a day), not months of schedule.
- Expand only the data the view renders: `artist` and/or `tags` when you show them, neither when you don't. One request per view covers it. If you also need the full artist directory, one `artists` call covers it; do not fetch artists per event.
- Schedule data changes rarely; static generation or short-lived caching works well for schedule pages.

## Final checklist

- Publishable key only in client code, never a secret key.
- `expand` matches the render: use `artist`/`tags` when you show that data (no per-artist follow-up requests), skip it when you don't.
- All times converted from UTC to an explicit display timezone, including day grouping.
- Descriptions rendered from TipTap JSON, not dumped raw.
- Artist links built from `slug`, `shareableLinkId` never exposed.
- Live "on now" and now-playing state driven by the player skill's WebSocket metadata, never by polling loops.
- Recently-played feed uses the `latest-results` endpoint, converts `playoutStart` from UTC, and is not confused with the live track.
