diff --git a/CHANGELOG.md b/CHANGELOG.md
index 12ef5a476760eede01cfb350bee04346fcb30103..1b1c0ac1b21e78f7b36a36ae05e0fcb8c22d272e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,9 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+### Added
+
+- `basic` and `calendar` program routes. (steering#239)
+
+
 ### Changed
 
 - Changed the aura user and group ID from 2872 to 872.
+- `playout` is now part of the program routes. (steering#120)
 
 ## [1.0.0-alpha4] - 2024-04-17
 
diff --git a/conftest.py b/conftest.py
index 2acabcdd1e35f84b3ffc7e0437799dc61b0f4ee8..bad250b0a709df8c7aed77c2c922e46cbcb7e46b 100644
--- a/conftest.py
+++ b/conftest.py
@@ -57,6 +57,27 @@ def assert_data(response, data) -> None:
             assert response.data[key] == value
 
 
+def create_daily_schedule(admin_api_client, daily_rrule, show) -> None:
+    """creates a schedule for a show that repeats daily using the REST API."""
+
+    now = datetime.now()
+    in_one_hour = now + timedelta(hours=1)
+    in_seven_days = now + timedelta(days=7)
+
+    data = {
+        "schedule": {
+            "end_time": in_one_hour.strftime("%H:%M:%S"),
+            "first_date": now.strftime("%Y-%m-%d"),
+            "last_date": in_seven_days.strftime("%Y-%m-%d"),
+            "rrule_id": daily_rrule.id,
+            "show_id": show.id,
+            "start_time": now.strftime("%H:%M:%S"),
+        }
+    }
+
+    admin_api_client.post("/api/v1/schedules/", data=data, format="json")
+
+
 @pytest.fixture
 def profile() -> Profile:
     return ProfileFactory()
diff --git a/program/filters.py b/program/filters.py
index e54eac1563cfc42428db31be00a26a07cef4aa12..60bbce5de6a0d766cbbc3bf807ac39d35d551041 100644
--- a/program/filters.py
+++ b/program/filters.py
@@ -8,6 +8,7 @@ from django import forms
 from django.db.models import Exists, OuterRef, QuerySet
 from django.utils import timezone
 from program import models
+from program.services import generate_program_entries
 
 
 class StaticFilterHelpTextMixin:
@@ -283,11 +284,31 @@ class ActiveFilterSet(StaticFilterHelpTextMixin, filters.FilterSet):
 
 
 class VirtualTimeslotFilterSet(filters.FilterSet):
+    start = filters.IsoDateTimeFilter(method="filter_noop")
+    end = filters.IsoDateTimeFilter(method="filter_noop")
     include_virtual = filters.BooleanFilter(
-        field_name="is_virtual",
         help_text="Include virtual timeslot entries (default: false).",
+        method="filter_noop",
     )
 
+    # Filters using the noop are implemented in the generate_program_entries generator.
+    # We do this, so that we have all the bells and whistles of the automatic value conversion,
+    # but can still implement custom filter logic on top.
+    def filter_noop(self, queryset: QuerySet, _: str, value: bool) -> QuerySet:
+        return queryset
+
+    def filter_queryset(self, queryset: QuerySet):
+        queryset = super().filter_queryset(queryset)
+        filter_data = self.form.cleaned_data
+        return list(
+            generate_program_entries(
+                queryset,
+                start=filter_data["start"],
+                end=filter_data["end"],
+                include_virtual=filter_data["include_virtual"],
+            )
+        )
+
     class Meta:
         model = models.TimeSlot
-        fields = ["include_virtual"]
+        fields = ["start", "end", "include_virtual"]
diff --git a/program/models.py b/program/models.py
index fe6ba333e0e139713af97af9ed23d17f8b4abf18..493de37bf5db050f2d5cbd0c21da8ccca06931dd 100644
--- a/program/models.py
+++ b/program/models.py
@@ -17,6 +17,9 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
+import dataclasses
+import datetime
+
 import jsonschema
 from rest_framework.exceptions import ValidationError
 from versatileimagefield.fields import PPOIField, VersatileImageField
@@ -687,3 +690,18 @@ class RadioSettings(models.Model):
 
     def __str__(self):
         return self.station_name
+
+
+@dataclasses.dataclass()
+class ProgramEntry:
+    id: str
+    start: datetime.datetime
+    end: datetime.datetime
+    show: Show
+    timeslot: TimeSlot | None
+
+    def playlist_id(self) -> int | None:
+        if self.timeslot and self.timeslot.playlist_id:
+            return self.timeslot.playlist_id
+        else:
+            return self.show.default_playlist_id
diff --git a/program/serializers.py b/program/serializers.py
index 015b01b729a0e7d7283a2ffec92a45a3675fa0c4..612324834b824908ef60db8578c1b5435ced3496 100644
--- a/program/serializers.py
+++ b/program/serializers.py
@@ -19,6 +19,7 @@
 #
 
 import re
+from functools import cached_property
 
 from drf_jsonschema_serializer import JSONSchemaField
 from rest_framework import serializers
@@ -28,6 +29,7 @@ from django.conf import settings
 from django.contrib.auth.models import User
 from django.core.exceptions import ObjectDoesNotExist
 from django.db import IntegrityError
+from django.db.models import Q
 from django.utils import text, timezone
 from program.models import (
     CBA,
@@ -42,6 +44,7 @@ from program.models import (
     NoteLink,
     Profile,
     ProfileLink,
+    ProgramEntry,
     RadioSettings,
     RRule,
     Schedule,
@@ -54,9 +57,6 @@ from program.models import (
 from program.typing import (
     Logo,
     MicroProgram,
-    NestedEpisode,
-    NestedSchedule,
-    NestedShow,
     ProgramFallback,
     RadioCBASettings,
     RadioImageRequirementsSettings,
@@ -1288,49 +1288,161 @@ class RadioSettingsSerializer(serializers.ModelSerializer):
         )
 
 
-# done this way to get the schema annotations for datetime right
-class NestedTimeslotSerializer(serializers.Serializer):
+class BasicProgramEntrySerializer(serializers.Serializer):
+    id = serializers.CharField()
+    start = serializers.DateTimeField()
     end = serializers.DateTimeField()
-    id = serializers.IntegerField(allow_null=True)
-    is_virtual = serializers.BooleanField()
-    memo = serializers.CharField()
+    timeslot_id = serializers.IntegerField(allow_null=True, source="timeslot.id")
     playlist_id = serializers.IntegerField(allow_null=True)
-    repetition_of_id = serializers.IntegerField(allow_null=True)
-    start = serializers.DateTimeField()
-
-
-class PlayoutEntrySerializer(serializers.Serializer):
-    episode = serializers.SerializerMethodField()
-    schedule = serializers.SerializerMethodField(allow_null=True)
-    show = serializers.SerializerMethodField()
-    timeslot = NestedTimeslotSerializer()
-
-    @staticmethod
-    def get_episode(obj) -> NestedEpisode:
-        pass
+    show_id = serializers.IntegerField(source="show.id")
+
+
+class PlayoutProgramEntrySerializer(BasicProgramEntrySerializer):
+    class PlayoutShowSerializer(serializers.ModelSerializer):
+        class Meta:
+            model = Show
+            fields = ["id", "name", "default_playlist_id"]
+
+    class PlayoutScheduleSerializer(serializers.ModelSerializer):
+        class Meta:
+            model = Schedule
+            fields = ["id", "default_playlist_id"]
+
+    class PlayoutEpisodeSerializer(serializers.ModelSerializer):
+        class Meta:
+            model = Note
+            fields = ["id", "title"]
+
+    timeslot = TimeSlotSerializer()
+    show = PlayoutShowSerializer()
+    episode = PlayoutEpisodeSerializer(allow_null=True, source="timeslot.note")
+    schedule = PlayoutScheduleSerializer(allow_null=True, source="timeslot.schedule")
+
+
+class CalendarSchemaSerializer(serializers.Serializer):
+    class Wrapper:
+        def __init__(self, program: list[ProgramEntry]):
+            self.program = program
+
+        @cached_property
+        def shows(self):
+            show_ids = set(entry.show.id for entry in self.program)
+            return Show.objects.distinct().filter(id__in=show_ids)
+
+        @cached_property
+        def timeslots(self):
+            timeslot_ids = set(entry.timeslot.id for entry in self.program if entry.timeslot)
+            return TimeSlot.objects.distinct().filter(id__in=timeslot_ids)
+
+        @cached_property
+        def episodes(self):
+            return Note.objects.distinct().filter(timeslot__in=self.timeslots)
+
+        @cached_property
+        def profiles(self):
+            return Profile.objects.distinct().filter(
+                Q(shows__in=self.shows) | Q(notes__in=self.episodes)
+            )
 
-    @staticmethod
-    def get_schedule(obj) -> NestedSchedule:
-        pass
+        @property
+        def categories(self):
+            return Category.objects.distinct().filter(shows__in=self.shows)
 
-    @staticmethod
-    def get_show(obj) -> NestedShow:
-        pass
+        @property
+        def funding_categories(self):
+            return FundingCategory.objects.distinct().filter(shows__in=self.shows)
 
+        @property
+        def types(self):
+            return Type.objects.distinct().filter(shows__in=self.shows)
 
-class ProgramEntrySerializer(serializers.Serializer):
-    episode = serializers.SerializerMethodField()
-    show = serializers.SerializerMethodField()
-    timeslot = NestedTimeslotSerializer()
+        @property
+        def topics(self):
+            return Topic.objects.distinct().filter(
+                Q(shows__in=self.shows) | Q(episodes__in=self.episodes)
+            )
 
-    @staticmethod
-    def get_episode(obj) -> NestedEpisode:
-        pass
+        @property
+        def languages(self):
+            return Language.objects.distinct().filter(
+                Q(shows__in=self.shows) | Q(episodes__in=self.episodes)
+            )
 
-    @staticmethod
-    def get_schedule(obj) -> NestedSchedule:
-        pass
+        @property
+        def music_focuses(self):
+            return MusicFocus.objects.distinct().filter(shows__in=self.shows)
+
+        @cached_property
+        def images(self):
+            return Image.objects.distinct().filter(
+                Q(logo_shows__in=self.shows)
+                | Q(shows__in=self.shows)
+                | Q(profiles__in=self.profiles)
+                | Q(notes__in=self.episodes)
+            )
 
-    @staticmethod
-    def get_show(obj) -> NestedShow:
-        pass
+        @property
+        def licenses(self):
+            return License.objects.distinct().filter(images__in=self.images)
+
+        @property
+        def link_types(self):
+            return LinkType.objects.all()
+
+    class CalendarTimeslotSerializer(TimeSlotSerializer):
+        class Meta(TimeSlotSerializer.Meta):
+            fields = [f for f in TimeSlotSerializer.Meta.fields if f != "memo"]
+
+    class CalendarEpisodeSerializer(NoteSerializer):
+        class Meta(NoteSerializer.Meta):
+            fields = [
+                field
+                for field in NoteSerializer.Meta.fields
+                if field not in ["created_at", "created_by", "updated_at", "updated_by"]
+            ]
+
+    class CalendarProfileSerializer(ProfileSerializer):
+        class Meta(ProfileSerializer.Meta):
+            fields = [
+                field
+                for field in ProfileSerializer.Meta.fields
+                if field
+                not in [
+                    "created_at",
+                    "created_by",
+                    "owner_ids",
+                    "updated_at",
+                    "updated_by",
+                ]
+            ]
+
+    class CalendarShowSerializer(ShowSerializer):
+        class Meta(ShowSerializer.Meta):
+            fields = [
+                field
+                for field in ShowSerializer.Meta.fields
+                if field
+                not in [
+                    "created_at",
+                    "created_by",
+                    "internal_note",
+                    "owner_ids",
+                    "updated_at",
+                    "updated_by",
+                ]
+            ]
+
+    shows = CalendarShowSerializer(many=True)
+    timeslots = CalendarTimeslotSerializer(many=True)
+    profiles = CalendarProfileSerializer(many=True)
+    categories = CategorySerializer(many=True)
+    funding_categories = FundingCategorySerializer(many=True)
+    types = TypeSerializer(many=True)
+    images = ImageSerializer(many=True)
+    topics = TopicSerializer(many=True)
+    languages = LanguageSerializer(many=True)
+    music_focuses = MusicFocusSerializer(many=True)
+    program = BasicProgramEntrySerializer(many=True)
+    episodes = CalendarEpisodeSerializer(many=True)
+    licenses = LicenseSerializer(many=True)
+    link_types = LinkTypeSerializer(many=True)
diff --git a/program/services.py b/program/services.py
index fd1e6406a808cdd0424f2492ecd2f8efd8ac1abc..23497ed65fdfa6b9834ef7cb91c291c0820af385 100644
--- a/program/services.py
+++ b/program/services.py
@@ -18,12 +18,12 @@
 #
 
 import copy
+from collections.abc import Iterator
 from datetime import datetime, time, timedelta
-from itertools import pairwise
 
 from dateutil.relativedelta import relativedelta
 from dateutil.rrule import rrule
-from rest_framework.exceptions import NotFound, ValidationError
+from rest_framework.exceptions import ValidationError
 
 from django.conf import settings
 from django.core.exceptions import ObjectDoesNotExist
@@ -32,6 +32,7 @@ from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 from program.models import (
     Note,
+    ProgramEntry,
     RadioSettings,
     RRule,
     Schedule,
@@ -40,17 +41,7 @@ from program.models import (
     TimeSlot,
 )
 from program.serializers import ScheduleSerializer, TimeSlotSerializer
-from program.typing import (
-    Conflicts,
-    DayScheduleEntry,
-    NestedEpisode,
-    NestedSchedule,
-    NestedShow,
-    NestedTimeslot,
-    ScheduleCreateUpdateData,
-    ScheduleData,
-    TimerangeEntry,
-)
+from program.typing import Conflicts, ScheduleCreateUpdateData, ScheduleData
 from program.utils import parse_date, parse_datetime, parse_time
 
 
@@ -708,146 +699,50 @@ def generate_conflicts(timeslots: list[TimeSlot]) -> Conflicts:
     return conflicts
 
 
-def make_day_schedule_entry(*, timerange_entry: TimerangeEntry) -> DayScheduleEntry:
-    """returns a day schedule entry for the given timerange entry."""
-
-    episode = timerange_entry["episode"]
-    timeslot = timerange_entry["timeslot"]
-    show = timerange_entry["show"]
-
-    return DayScheduleEntry(
-        episode=NestedEpisode(
-            id=episode.get("id"),
-            title=episode["title"],
-        ),
-        timeslot=NestedTimeslot(
-            end=timeslot["end"],
-            id=timeslot.get("id"),
-            is_virtual=bool(timeslot["is_virtual"]),
-            memo=timeslot.get("memo"),
-            playlist_id=timeslot.get("playlist_id"),
-            repetition_of_id=timeslot.get("repetition_of_id"),
-            start=timeslot["start"],
-        ),
-        show=NestedShow(
-            default_playlist_id=show["default_playlist_id"],
-            id=show["id"],
-            name=show["name"],
-        ),
-    )
-
-
-def make_timerange_entry(*, timeslot: TimeSlot) -> TimerangeEntry:
-    """returns a timerange entry for the given timeslot."""
-
-    episode = timeslot.note
-    schedule = timeslot.schedule
-    show = timeslot.schedule.show
-
-    return TimerangeEntry(
-        episode=NestedEpisode(
-            id=episode.id,
-            title=episode.title,
-        ),
-        schedule=NestedSchedule(
-            default_playlist_id=schedule.default_playlist_id,
-            id=schedule.id,
-        ),
-        show=NestedShow(
-            default_playlist_id=show.default_playlist_id,
-            id=show.id,
-            name=show.name,
-        ),
-        timeslot=NestedTimeslot(
-            end=timeslot.end.strftime("%Y-%m-%dT%H:%M:%S"),
-            id=timeslot.id,
-            is_virtual=False,
-            memo=timeslot.memo,
-            playlist_id=timeslot.playlist_id,
-            repetition_of_id=timeslot.repetition_of.id if timeslot.repetition_of_id else None,
-            start=timeslot.start.strftime("%Y-%m-%dT%H:%M:%S"),
-        ),
-    )
-
-
-def make_virtual_timerange_entry(*, gap_start: datetime, gap_end: datetime) -> TimerangeEntry:
-    """returns a timerange entry to fill the gap between `gap_start` and `gap_end`."""
-
-    if radio_settings := RadioSettings.objects.first():
-        fallback_show = radio_settings.fallback_show
-        return TimerangeEntry(
-            episode=NestedEpisode(
-                id=None,
-                title=radio_settings.pools[radio_settings.fallback_default_pool],
-            ),
-            schedule=None,
-            show=NestedShow(
-                default_playlist_id=fallback_show.default_playlist_id if fallback_show else None,
-                id=fallback_show.id if fallback_show else None,
-                name=fallback_show.name if fallback_show else "",
-            ),
-            timeslot=NestedTimeslot(
-                end=gap_end.strftime("%Y-%m-%dT%H:%M:%S"),
-                id=None,
-                is_virtual=True,
-                memo="",
-                playlist_id=None,
-                repetition_of_id=None,
-                start=gap_start.strftime("%Y-%m-%dT%H:%M:%S"),
-            ),
-        )
-    else:
-        raise NotFound(
-            detail=_("Radio settings with fallbacks not found."), code="radio_settings-not_found"
-        )
-
-
-def get_timerange_timeslot_entries(
-    timerange_start: datetime, timerange_end: datetime, include_virtual: bool = False
-) -> list[TimerangeEntry]:
+def generate_program_entries(
+    queryset: QuerySet[TimeSlot],
+    *,
+    start: datetime | None,
+    end: datetime | None,
+    include_virtual: bool,
+) -> Iterator[ProgramEntry]:
     """Gets list of timerange entries between the given `timerange_start` and `timerange_end`.
 
     Include virtual timerange entries if requested."""
 
-    timeslots = TimeSlot.objects.filter(
-        # start before `timerange_start` and end after `timerange_start`
-        Q(start__lt=timerange_start, end__gt=timerange_start)
-        # start after/at `timerange_start`, end before/at `timerange_end`
-        | Q(start__gte=timerange_start, end__lte=timerange_end)
-        # start before `timerange_end`, end after/at `timerange_end`
-        | Q(start__lt=timerange_end, end__gte=timerange_end)
-    ).select_related("schedule")
-
-    if not include_virtual:
-        return [make_timerange_entry(timeslot=timeslot) for timeslot in timeslots]
-
-    if not timeslots:
-        return []
-
-    timeslot_entries = []
-    # gap before the first timeslot
-    first_timeslot = timeslots.first()
-    if first_timeslot.start > timerange_start:
-        timeslot_entries.append(
-            make_virtual_timerange_entry(gap_start=timerange_start, gap_end=first_timeslot.start)
+    def create_entry(start: datetime, end: datetime, show: Show, timeslot: TimeSlot | None = None):
+        return ProgramEntry(
+            id=f"{start.isoformat()}...{end.isoformat()}",
+            start=start,
+            end=end,
+            timeslot=timeslot,
+            show=show,
         )
 
-    for index, (current, upcoming) in enumerate(pairwise(timeslots)):
-        timeslot_entries.append(make_timerange_entry(timeslot=current))
+    def create_timeslot_entry(timeslot: TimeSlot):
+        return create_entry(timeslot.start, timeslot.end, timeslot.schedule.show, timeslot)
 
-        # gap between the timeslots
-        if current.end != upcoming.start:
-            timeslot_entries.append(
-                make_virtual_timerange_entry(gap_start=current.end, gap_end=upcoming.start)
-            )
-    else:
-        timeslot_entries.append(make_timerange_entry(timeslot=first_timeslot))
+    if start is None:
+        start = timezone.now()
+    if end is None:
+        end = start + timedelta(days=1)
+    queryset = queryset.filter(start__gte=start, start__lt=end)
 
-    # gap after the last timeslot
-    last_timeslot = timeslots.last()
-    if last_timeslot.end < timerange_end:
-        timeslot_entries.append(
-            make_virtual_timerange_entry(gap_start=last_timeslot.end, gap_end=timerange_end)
-        )
-
-    return timeslot_entries
+    if not include_virtual:
+        yield from (create_timeslot_entry(timeslot) for timeslot in queryset)
+        return
+
+    radio_settings: RadioSettings | None = RadioSettings.objects.first()
+    fallback_show = radio_settings.fallback_show if radio_settings is not None else None
+    if fallback_show is None:
+        raise ValueError("Radio settings must set fallback show if include_virtual is True.")
+
+    entry_start = start
+    timeslot: TimeSlot
+    for timeslot in queryset:
+        if timeslot.start > entry_start:
+            yield create_entry(entry_start, timeslot.start, fallback_show)
+        yield create_timeslot_entry(timeslot)
+        entry_start = timeslot.end
+    if entry_start != end:
+        yield create_entry(entry_start, end, fallback_show)
diff --git a/program/tests/test_basic_program.py b/program/tests/test_basic_program.py
new file mode 100644
index 0000000000000000000000000000000000000000..bfc08892027b9a050dbe4883e9d1f3e5f6fb4e88
--- /dev/null
+++ b/program/tests/test_basic_program.py
@@ -0,0 +1,128 @@
+from datetime import datetime, timedelta
+from itertools import pairwise
+
+import pytest
+
+from conftest import create_daily_schedule
+
+pytestmark = pytest.mark.django_db
+
+
+def url(include_virtual=False, start=None, end=None):
+    if include_virtual and start and end:
+        return f"/api/v1/program/basic/?include_virtual=true&start={start}&end={end}"
+    elif start and end:
+        return f"/api/v1/program/basic/?start={start}&end={end}"
+    elif include_virtual:
+        return "/api/v1/program/basic/?include_virtual=true"
+    else:
+        return "/api/v1/program/basic/"
+
+
+def assert_entry(entry, show) -> None:
+    """asserts the playout entry corresponds to the given show."""
+
+    assert entry["end"]
+    assert entry["id"]
+    assert "playlistId" in entry
+    assert entry["start"]
+    assert entry["timeslotId"]
+
+    assert entry["showId"] == show.id
+
+
+def assert_virtual_entry(entry, fallback_show) -> None:
+    """asserts the playout entry is virtual and corresponds to given fallback show."""
+
+    assert entry["end"]
+    assert entry["id"]
+    assert "playlistId" in entry
+    assert entry["start"]
+
+    assert not entry["timeslotId"]
+
+    assert entry["showId"] == fallback_show.id
+
+
+def test_basic(admin_api_client, api_client, daily_rrule, show):
+    create_daily_schedule(admin_api_client, daily_rrule, show)
+
+    response = api_client.get(url())
+
+    assert response.status_code == 200
+    assert len(response.json()) == 1
+
+    assert_entry(response.json()[0], show)
+
+
+def test_basic_one_week(admin_api_client, api_client, daily_rrule, show):
+    create_daily_schedule(admin_api_client, daily_rrule, show)
+
+    now = datetime.now()
+    in_one_week = now + timedelta(days=7)
+
+    response = api_client.get(url(start=now.isoformat(), end=in_one_week.isoformat()))
+
+    assert response.status_code == 200
+    assert len(response.json()) == 6 or 7  # I’m not sure why, but this changes around midnight.
+
+    for entry in response.json():
+        assert_entry(entry, show)
+
+
+def test_basic_include_virtual(
+    admin_api_client,
+    api_client,
+    daily_rrule,
+    show,
+    fallback_show,
+    radio_settings,
+):
+
+    create_daily_schedule(admin_api_client, daily_rrule, show)
+
+    response = api_client.get(url(include_virtual=True))
+
+    assert response.status_code == 200
+    assert len(response.json()) == 3
+
+    virtual_entry1, entry, virtual_entry2 = response.json()
+
+    assert_virtual_entry(virtual_entry1, fallback_show)
+    assert_entry(entry, show)
+    assert_virtual_entry(virtual_entry2, fallback_show)
+
+    assert virtual_entry1["end"] == entry["start"]
+    assert entry["end"] == virtual_entry2["start"]
+
+
+def test_basic_one_week_include_virtual(
+    admin_api_client,
+    api_client,
+    daily_rrule,
+    show,
+    fallback_show,
+    radio_settings,
+):
+    create_daily_schedule(admin_api_client, daily_rrule, show)
+
+    now = datetime.now()
+    in_one_week = now + timedelta(days=7)
+
+    response = api_client.get(
+        url(include_virtual=True, start=now.isoformat(), end=in_one_week.isoformat())
+    )
+
+    assert response.status_code == 200
+    assert len(response.json()) == 13 or 15  # I’m not sure why, but this changes around midnight.
+
+    entries = response.json()
+
+    for virtual_entry in entries[0::2]:
+        assert_virtual_entry(virtual_entry, fallback_show)
+
+    for entry in entries[1::2]:
+        assert_entry(entry, show)
+
+    for entry1, entry2 in pairwise(entries):
+        assert entry1["end"] == entry2["start"]
diff --git a/program/tests/test_calendar_program.py b/program/tests/test_calendar_program.py
new file mode 100644
index 0000000000000000000000000000000000000000..417f840fb2f0346d5161c6efb2cbfb82f5317bae
--- /dev/null
+++ b/program/tests/test_calendar_program.py
@@ -0,0 +1,174 @@
+from datetime import datetime, timedelta
+
+import pytest
+
+from conftest import create_daily_schedule
+
+pytestmark = pytest.mark.django_db
+
+
+def url(include_virtual=False, start=None, end=None):
+    if include_virtual and start and end:
+        return f"/api/v1/program/calendar/?include_virtual=true&start={start}&end={end}"
+    elif start and end:
+        return f"/api/v1/program/calendar/?start={start}&end={end}"
+    elif include_virtual:
+        return "/api/v1/program/calendar/?include_virtual=true"
+    else:
+        return "/api/v1/program/calendar/"
+
+
+def assert_episodes(episodes, one_week=False) -> None:
+    """asserts the episodes are valid."""
+
+    assert len(episodes) == 1 if not one_week else 7
+
+    for episode in episodes:
+        assert episode["id"]
+        assert episode["timeslotId"]
+
+
+def assert_program(program, show, fallback_show=None, one_week=False) -> None:
+    """asserts the program are valid and correspond to the given show and fallback show."""
+
+    assert len(program) == 1 if fallback_show is None and not one_week else (7 if one_week else 3)
+
+    assert program[0]["end"]
+    assert program[0]["id"]
+    assert program[0]["start"]
+
+    if fallback_show is None:
+        assert program[0]["showId"] == show.id
+        assert program[0]["timeslotId"]
+    else:
+        assert program[0]["showId"] == fallback_show.id
+
+        assert program[1]["showId"] == show.id
+        assert program[1]["timeslotId"]
+
+
+def assert_shows(shows, show, fallback_show=None) -> None:
+    """asserts the shows are valid correspond to the given show and fallback show."""
+
+    assert len(shows) == 1 if fallback_show is None else 2
+
+    if fallback_show is None:
+        assert shows[0]["id"] == show.id
+        assert shows[0]["name"] == show.name
+    else:
+        assert shows[0]["id"] == show.id
+        assert shows[0]["name"] == show.name
+
+        assert shows[1]["id"] == fallback_show.id
+        assert shows[1]["name"] == fallback_show.name
+
+
+def assert_timeslots(timeslots, show, one_week=False) -> None:
+    """asserts the timeslots are valid and correspond to the given show."""
+
+    assert len(timeslots) == 1 if not one_week else 7
+
+    for timeslot in timeslots:
+        assert timeslot["id"]
+        assert timeslot["noteId"]
+        assert timeslot["end"]
+        assert timeslot["start"]
+        assert timeslot["showId"] == show.id
+
+
+def test_calendar(admin_api_client, api_client, daily_rrule, show):
+    create_daily_schedule(admin_api_client, daily_rrule, show)
+
+    response = api_client.get(url())
+
+    assert response.status_code == 200
+
+    for key, value in response.json().items():
+        match key:
+            case "episodes":
+                assert_episodes(value)
+            case "program":
+                assert_program(value, show)
+            case "shows":
+                assert_shows(value, show)
+            case "timeslots":
+                assert_timeslots(value, show)
+
+
+def test_calendar_one_week(admin_api_client, api_client, daily_rrule, show):
+    create_daily_schedule(admin_api_client, daily_rrule, show)
+
+    now = datetime.now()
+    in_one_week = now + timedelta(days=7)
+
+    response = api_client.get(url(start=now.isoformat(), end=in_one_week.isoformat()))
+
+    assert response.status_code == 200
+
+    for key, value in response.json().items():
+        match key:
+            case "episodes":
+                assert_episodes(value, one_week=True)
+            case "program":
+                assert_program(value, show, one_week=True)
+            case "shows":
+                assert_shows(value, show)
+            case "timeslots":
+                assert_timeslots(value, show, one_week=True)
+
+
+def test_calendar_include_virtual(
+    admin_api_client,
+    api_client,
+    daily_rrule,
+    show,
+    fallback_show,
+    radio_settings,
+):
+    create_daily_schedule(admin_api_client, daily_rrule, show)
+
+    response = api_client.get(url(include_virtual=True))
+
+    assert response.status_code == 200
+
+    for key, value in response.json().items():
+        match key:
+            case "episodes":
+                assert_episodes(value)
+            case "program":
+                assert_program(value, show, fallback_show)
+            case "shows":
+                assert_shows(value, show, fallback_show)
+            case "timeslots":
+                assert_timeslots(value, show)
+
+
+def test_calendar_one_week_include_virtual(
+    admin_api_client,
+    api_client,
+    daily_rrule,
+    show,
+    fallback_show,
+    radio_settings,
+):
+    create_daily_schedule(admin_api_client, daily_rrule, show)
+
+    now = datetime.now()
+    in_one_week = now + timedelta(days=7)
+
+    response = api_client.get(
+        url(include_virtual=True, start=now.isoformat(), end=in_one_week.isoformat())
+    )
+
+    assert response.status_code == 200
+
+    for key, value in response.json().items():
+        match key:
+            case "episodes":
+                assert_episodes(value, one_week=True)
+            case "program":
+                assert_program(value, show=show, fallback_show=fallback_show, one_week=True)
+            case "shows":
+                assert_shows(value, show=show, fallback_show=fallback_show)
+            case "timeslots":
+                assert_timeslots(value, show=show, one_week=True)
diff --git a/program/tests/test_playout.py b/program/tests/test_playout.py
index f1e6db062f809da4c2686810971b0f56e166f6d1..acf2c217a01dbee02c8751c5e15a0f88ffe4b2de 100644
--- a/program/tests/test_playout.py
+++ b/program/tests/test_playout.py
@@ -1,36 +1,48 @@
 from datetime import datetime, timedelta
+from itertools import pairwise
 
 import pytest
 
+from conftest import create_daily_schedule
+
 pytestmark = pytest.mark.django_db
 
 
-def url(include_virtual=False):
-    if include_virtual:
-        return "/api/v1/playout/?include_virtual=true"
+def url(include_virtual=False, start=None, end=None):
+    if include_virtual and start and end:
+        return f"/api/v1/program/playout/?include_virtual=true&start={start}&end={end}"
+    elif start and end:
+        return f"/api/v1/program/playout/?start={start}&end={end}"
+    elif include_virtual:
+        return "/api/v1/program/playout/?include_virtual=true"
+    else:
+        return "/api/v1/program/playout/"
 
-    return "/api/v1/playout/"
 
+def assert_entry(entry, show) -> None:
+    """asserts the playout entry corresponds to the given show."""
 
-def create_daily_schedule(admin_api_client, daily_rrule, show) -> None:
-    """creates a schedule for a show that repeats daily using the REST API."""
+    assert entry["episode"]
+    assert entry["schedule"]
+    assert entry["show"]
+    assert entry["timeslot"]
+    assert entry["timeslotId"]
+
+    assert entry["showId"] == entry["show"]["id"] == show.id
+    assert entry["show"]["name"] == show.name
 
-    now = datetime.now()
-    in_one_hour = now + timedelta(hours=1)
-    in_seven_days = now + timedelta(days=7)
 
-    data = {
-        "schedule": {
-            "end_time": in_one_hour.strftime("%H:%M:%S"),
-            "first_date": now.strftime("%Y-%m-%d"),
-            "last_date": in_seven_days.strftime("%Y-%m-%d"),
-            "rrule_id": daily_rrule.id,
-            "show_id": show.id,
-            "start_time": now.strftime("%H:%M:%S"),
-        }
-    }
+def assert_virtual_entry(entry, fallback_show) -> None:
+    """asserts the playout entry is virtual and corresponds to given fallback show."""
 
-    admin_api_client.post("/api/v1/schedules/", data=data, format="json")
+    assert entry["show"]
+    assert not entry["episode"]
+    assert not entry["schedule"]
+    assert not entry["timeslot"]
+    assert not entry["timeslotId"]
+
+    assert entry["showId"] == entry["show"]["id"] == fallback_show.id
+    assert entry["show"]["name"] == fallback_show.name
 
 
 def test_playout(admin_api_client, api_client, daily_rrule, show):
@@ -39,12 +51,24 @@ def test_playout(admin_api_client, api_client, daily_rrule, show):
     response = api_client.get(url())
 
     assert response.status_code == 200
-    assert len(response.json()) == 7
+    assert len(response.json()) == 1
+
+    assert_entry(response.json()[0], show)
+
+
+def test_playout_one_week(admin_api_client, api_client, daily_rrule, show):
+    create_daily_schedule(admin_api_client, daily_rrule, show)
+
+    now = datetime.now()
+    in_one_week = now + timedelta(days=7)
+
+    response = api_client.get(url(start=now.isoformat(), end=in_one_week.isoformat()))
+
+    assert response.status_code == 200
+    assert len(response.json()) == 6 or 7  # I’m not sure why, but this changes around midnight.
 
     for entry in response.json():
-        assert not entry["timeslot"]["isVirtual"]
-        assert entry["show"]["id"] == show.id
-        assert entry["show"]["name"] == show.name
+        assert_entry(entry, show)
 
 
 def test_playout_include_virtual(
@@ -60,12 +84,45 @@ def test_playout_include_virtual(
     response = api_client.get(url(include_virtual=True))
 
     assert response.status_code == 200
-    assert len(response.json()) == 14 or 15
+    assert len(response.json()) == 3
 
-    for entry in response.json():
-        if entry["timeslot"]["isVirtual"]:
-            assert entry["show"]["id"] == fallback_show.id
-            assert entry["show"]["name"] == fallback_show.name
-        else:
-            assert entry["show"]["id"] == show.id
-            assert entry["show"]["name"] == show.name
+    virtual_entry1, entry, virtual_entry2 = response.json()
+
+    assert_virtual_entry(virtual_entry1, fallback_show)
+    assert_entry(entry, show)
+    assert_virtual_entry(virtual_entry2, fallback_show)
+
+    assert virtual_entry1["end"] == entry["start"]
+    assert entry["end"] == virtual_entry2["start"]
+
+
+def test_playout_one_week_include_virtual(
+    admin_api_client,
+    api_client,
+    daily_rrule,
+    show,
+    fallback_show,
+    radio_settings,
+):
+    create_daily_schedule(admin_api_client, daily_rrule, show)
+
+    now = datetime.now()
+    in_one_week = now + timedelta(days=7)
+
+    response = api_client.get(
+        url(include_virtual=True, start=now.isoformat(), end=in_one_week.isoformat())
+    )
+
+    assert response.status_code == 200
+    assert len(response.json()) == 13 or 15  # I’m not sure why, but this changes around midnight.
+
+    entries = response.json()
+
+    for virtual_entry in entries[0::2]:
+        assert_virtual_entry(virtual_entry, fallback_show)
+
+    for entry in entries[1::2]:
+        assert_entry(entry, show)
+
+    for entry1, entry2 in pairwise(entries):
+        assert entry1["end"] == entry2["start"]
diff --git a/program/tests/test_program.py b/program/tests/test_program.py
deleted file mode 100644
index e29c54339fc3708981a73225a83684fb12ed998a..0000000000000000000000000000000000000000
--- a/program/tests/test_program.py
+++ /dev/null
@@ -1,73 +0,0 @@
-from datetime import datetime, timedelta
-
-import pytest
-
-pytestmark = pytest.mark.django_db
-
-
-def url(include_virtual=False):
-    year, month, day = datetime.today().year, datetime.today().month, datetime.today().day
-
-    if include_virtual:
-        return f"/api/v1/program/{year}/{month}/{day}/?include_virtual=true"
-    else:
-        return f"/api/v1/program/{year}/{month}/{day}/"
-
-
-def create_once_schedule(admin_api_client, once_rrule, show) -> None:
-    """creates a schedule for a show that repeats once using the REST API."""
-
-    now = datetime.now()
-    in_an_hour = now + timedelta(hours=1)
-
-    data = {
-        "schedule": {
-            "end_time": in_an_hour.strftime("%H:%M:%S"),
-            "first_date": now.strftime("%Y-%m-%d"),
-            "last_date": None,
-            "rrule_id": once_rrule.id,
-            "show_id": show.id,
-            "start_time": now.strftime("%H:%M:%S"),
-        },
-    }
-
-    admin_api_client.post("/api/v1/schedules/", data=data, format="json")
-
-
-def test_day_schedule(admin_api_client, api_client, once_rrule, show):
-    create_once_schedule(admin_api_client, once_rrule, show)
-
-    response = api_client.get(url())
-
-    assert response.status_code == 200
-    assert len(response.json()) == 1
-
-    entry = response.json()[0]
-
-    assert not entry["timeslot"]["isVirtual"]
-    assert entry["show"]["id"] == show.id
-    assert entry["show"]["name"] == show.name
-
-
-def test_day_schedule_include_virtual(
-    admin_api_client,
-    api_client,
-    once_rrule,
-    show,
-    fallback_show,
-    radio_settings,
-):
-    create_once_schedule(admin_api_client, once_rrule, show)
-
-    response = api_client.get(url(include_virtual=True))
-
-    assert response.status_code == 200
-    assert len(response.json()) == 3
-
-    for entry in response.json():
-        if entry["timeslot"]["isVirtual"]:
-            assert entry["show"]["id"] == fallback_show.id
-            assert entry["show"]["name"] == fallback_show.name
-        else:
-            assert entry["show"]["id"] == show.id
-            assert entry["show"]["name"] == show.name
diff --git a/program/tests/test_schedules.py b/program/tests/test_schedules.py
index a7185f015f74211ff956817e728a30f657c536ab..0416a0f56a45ccb7b3e22f8ac8ed3a9fef1f628e 100644
--- a/program/tests/test_schedules.py
+++ b/program/tests/test_schedules.py
@@ -240,7 +240,7 @@ def test_patch_set_is_repetition_true(admin_api_client, once_schedule):
     update = {"is_repetition": "true"}
 
     response = admin_api_client.patch(url(schedule=once_schedule), data=update)
-    print(response.request.items())
+
     assert response.status_code == 200
     assert response.data["is_repetition"] is True
 
diff --git a/program/views.py b/program/views.py
index 6b447cec6efb29016e032bdc422055583f6394be..8d151b575e6cde9e221ccc8f7699b9d100ed27e6 100644
--- a/program/views.py
+++ b/program/views.py
@@ -19,11 +19,10 @@
 #
 
 import logging
-from datetime import date, datetime, time, timedelta
+from datetime import date, datetime
 from textwrap import dedent
 
 from django_filters.rest_framework import DjangoFilterBackend
-from djangorestframework_camel_case.util import camelize
 from drf_spectacular.utils import (
     OpenApiExample,
     OpenApiParameter,
@@ -65,6 +64,8 @@ from program.models import (
     Type,
 )
 from program.serializers import (
+    BasicProgramEntrySerializer,
+    CalendarSchemaSerializer,
     CategorySerializer,
     ErrorSerializer,
     FundingCategorySerializer,
@@ -75,9 +76,8 @@ from program.serializers import (
     LinkTypeSerializer,
     MusicFocusSerializer,
     NoteSerializer,
-    PlayoutEntrySerializer,
+    PlayoutProgramEntrySerializer,
     ProfileSerializer,
-    ProgramEntrySerializer,
     RadioSettingsSerializer,
     RRuleSerializer,
     ScheduleConflictResponseSerializer,
@@ -91,172 +91,440 @@ from program.serializers import (
     TypeSerializer,
     UserSerializer,
 )
-from program.services import (
-    get_timerange_timeslot_entries,
-    make_day_schedule_entry,
-    resolve_conflicts,
-)
-from program.utils import get_values, parse_date
+from program.services import resolve_conflicts
+from program.utils import get_values
 
 logger = logging.getLogger(__name__)
 
 
+class AbstractAPIProgramViewSet(
+    mixins.ListModelMixin,
+    viewsets.GenericViewSet,
+):
+    filterset_class = filters.VirtualTimeslotFilterSet
+    queryset = TimeSlot.objects.all()
+
+
 @extend_schema_view(
     list=extend_schema(
-        summary="List schedule for a specific date.",
-        description=(
-            "Returns a list of the schedule for a specific date."
-            "Expects parameters `year` (int), `month` (int), and `day` (int) as url components."
-            "e.g. /program/2024/01/31/"
-        ),
         examples=[
             OpenApiExample(
-                "Full entry",
-                response_only=True,
+                "Example entry",
                 value={
-                    "episode": {"id": 2, "title": ""},
-                    "show": {"defaultPlaylistId": None, "id": 2, "name": "EINS"},
-                    "timeslot": {
-                        "end": "2024-07-03T16:30:00",
-                        "id": 2,
-                        "isVirtual": False,
-                        "memo": "",
-                        "playlistId": None,
-                        "repetitionOfId": None,
-                        "start": "2024-07-03T14:00:00",
-                    },
+                    "end": "2024-07-15T15:30:00-04:00",
+                    "id": "2024-07-15T19:07:38.604349+00:00...2024-07-15T19:30:00+00:00",
+                    "playlistId": None,
+                    "showId": 1,
+                    "start": "2024-07-15T15:07:38.604349-04:00",
+                    "timeslotId": None,
                 },
             ),
             OpenApiExample(
-                "Virtual entry",
-                response_only=True,
+                "Example virtual entry",
                 value={
-                    "episode": {"id": None, "title": "Station Fallback Pool"},
-                    "show": {"defaultPlaylistId": None, "id": 1, "name": "Musikpool"},
-                    "timeslot": {
-                        "end": "2024-07-04T00:00:00",
-                        "id": None,
-                        "isVirtual": True,
-                        "memo": "",
-                        "playlistId": None,
-                        "repetitionOfId": None,
-                        "start": "2024-07-03T22:00:00",
-                    },
+                    "end": "2024-07-15T18:00:00-04:00",
+                    "id": "2024-07-15T19:30:00+00:00...2024-07-15T22:00:00+00:00",
+                    "playlistId": None,
+                    "showId": 3,
+                    "start": "2024-07-15T15:30:00-04:00",
+                    "timeslotId": 141,
                 },
             ),
         ],
+        summary=(
+            "List program for a specific date range. "
+            "Only returns the most basic data for clients that fetch other data themselves."
+        ),
     ),
 )
-class APIProgramViewSet(
-    mixins.ListModelMixin,
-    viewsets.GenericViewSet,
-):
-    filterset_class = filters.VirtualTimeslotFilterSet
-    queryset = TimeSlot.objects.all()
-    serializer_class = ProgramEntrySerializer
-
-    def list(self, request, year=None, month=None, day=None):
-        # datetime.combine returns a timezone naive datetime object
-        if year is None and month is None and day is None:
-            start = timezone.make_aware(datetime.combine(timezone.now(), time(0, 0)))
-        else:
-            start = timezone.make_aware(datetime.combine(date(year, month, day), time(0, 0)))
-
-        end = start + timedelta(hours=24)
-
-        include_virtual = request.GET.get("include_virtual") == "true"
-
-        schedule = [
-            make_day_schedule_entry(timerange_entry=timeslot_entry)
-            for timeslot_entry in get_timerange_timeslot_entries(start, end, include_virtual)
-        ]
-
-        return JsonResponse(camelize(schedule), safe=False)
+class APIProgramBasicViewSet(AbstractAPIProgramViewSet):
+    serializer_class = BasicProgramEntrySerializer
 
 
 @extend_schema_view(
     list=extend_schema(
-        summary="List scheduled playout.",
-        description=(
-            "Returns a list of the scheduled playout. "
-            "The schedule will include virtual timeslots to fill unscheduled gaps if requested."
-        ),
-        # TODO: move this into the serializers
         examples=[
             OpenApiExample(
-                "Full entry",
-                response_only=True,
+                "Example entry",
                 value={
-                    "episode": {"id": 2, "title": ""},
+                    "end": "2024-07-16T12:30:00-04:00",
+                    "episode": {"id": 11, "title": ""},
+                    "id": "2024-07-16T14:00:00+00:00...2024-07-16T16:30:00+00:00",
+                    "playlistId": None,
                     "schedule": {"defaultPlaylistId": None, "id": 1},
                     "show": {"defaultPlaylistId": None, "id": 2, "name": "EINS"},
+                    "showId": 2,
+                    "start": "2024-07-16T10:00:00-04:00",
                     "timeslot": {
-                        "end": "2024-07-03T16:30:00",
-                        "id": 2,
-                        "isVirtual": False,
+                        "end": "2024-07-16T12:30:00-04:00",
+                        "id": 11,
                         "memo": "",
+                        "noteId": 11,
                         "playlistId": None,
                         "repetitionOfId": None,
-                        "start": "2024-07-03T14:00:00",
+                        "scheduleId": 1,
+                        "showId": 2,
+                        "start": "2024-07-16T10:00:00-04:00",
                     },
+                    "timeslotId": 11,
                 },
             ),
             OpenApiExample(
-                "Virtual entry",
-                response_only=True,
+                "Example virtual entry",
                 value={
-                    "episode": {"id": None, "title": "Station Fallback Pool"},
+                    "end": "2024-07-16T15:23:34.084852-04:00",
+                    "episode": None,
+                    "id": "2024-07-16T16:30:00+00:00...2024-07-16T19:23:34.084852+00:00",
+                    "playlistId": None,
                     "schedule": None,
                     "show": {"defaultPlaylistId": None, "id": 1, "name": "Musikpool"},
-                    "timeslot": {
-                        "end": "2024-07-10T00:00:00",
-                        "id": None,
-                        "isVirtual": True,
-                        "start": "2024-07-09T22:00:00",
-                    },
+                    "showId": 1,
+                    "start": "2024-07-16T12:30:00-04:00",
+                    "timeslot": None,
+                    "timeslotId": None,
                 },
             ),
         ],
-    )
+        summary=(
+            "List program for a specific date range. "
+            "Returns an extended program dataset for use in the AURA engine. "
+            "Not recommended for other tools."
+        ),
+    ),
 )
-class APIPlayoutViewSet(
-    mixins.ListModelMixin,
-    viewsets.GenericViewSet,
-):
-    filterset_class = filters.VirtualTimeslotFilterSet
-    queryset = TimeSlot.objects.all()
-    serializer_class = PlayoutEntrySerializer
+class APIProgramPlayoutViewSet(AbstractAPIProgramViewSet):
+    serializer_class = PlayoutProgramEntrySerializer
 
-    def list(self, request, *args, **kwargs):
-        """
-        Return a JSON representation of the scheduled playout.
-        Called by
-        - engine (playout) to retrieve timeslots within a given timerange
-        - internal calendar to retrieve all timeslots for a week
-        """
 
-        # datetime.now and datetime.combine return timezone naive datetime objects
-        if request.GET.get("start") is None:
-            schedule_start = timezone.make_aware(datetime.combine(datetime.now(), time(0, 0)))
-        else:
-            schedule_start = timezone.make_aware(
-                datetime.combine(parse_date(request.GET.get("start")), time(0, 0))
-            )
+class APIProgramCalendarViewSet(AbstractAPIProgramViewSet):
+    serializer_class = CalendarSchemaSerializer
 
-        if request.GET.get("end") is None:
-            schedule_end = schedule_start + timedelta(days=7)
-        else:
-            schedule_end = timezone.make_aware(
-                datetime.combine(
-                    parse_date(request.GET.get("end")) + timedelta(days=1), time(0, 0)
-                )
+    @extend_schema(
+        examples=[
+            OpenApiExample(
+                "Example entry with virtual timeslots",
+                value={
+                    "shows": [
+                        {
+                            "categoryIds": [5],
+                            "cbaSeriesId": None,
+                            "defaultPlaylistId": None,
+                            "description": "",
+                            "email": "ernesto@helsinki.at",
+                            "fundingCategoryId": 1,
+                            "hostIds": [1],
+                            "id": 4,
+                            "imageId": None,
+                            "isActive": True,
+                            "isPublic": True,
+                            "languageIds": [1, 3],
+                            "links": [{"typeId": 1, "url": "https://helsinki.at"}],
+                            "logoId": None,
+                            "musicFocusIds": [4],
+                            "name": "DREI",
+                            "predecessorId": None,
+                            "shortDescription": "chores",
+                            "slug": "drei",
+                            "topicIds": [5],
+                            "typeId": 2,
+                        },
+                        {
+                            "categoryIds": [],
+                            "cbaSeriesId": None,
+                            "defaultPlaylistId": None,
+                            "description": "",
+                            "email": "",
+                            "fundingCategoryId": 1,
+                            "hostIds": [1],
+                            "id": 2,
+                            "imageId": None,
+                            "isActive": True,
+                            "isPublic": True,
+                            "languageIds": [],
+                            "links": [],
+                            "logoId": None,
+                            "musicFocusIds": [],
+                            "name": "EINS",
+                            "predecessorId": None,
+                            "shortDescription": "shallow work",
+                            "slug": "eins",
+                            "topicIds": [],
+                            "typeId": 5,
+                        },
+                        {
+                            "categoryIds": [],
+                            "cbaSeriesId": None,
+                            "defaultPlaylistId": None,
+                            "description": "",
+                            "email": "",
+                            "fundingCategoryId": 1,
+                            "hostIds": [],
+                            "id": 1,
+                            "imageId": None,
+                            "isActive": True,
+                            "isPublic": True,
+                            "languageIds": [],
+                            "links": [],
+                            "logoId": None,
+                            "musicFocusIds": [],
+                            "name": "Musikpool",
+                            "predecessorId": None,
+                            "shortDescription": "Musik aus der Dose",
+                            "slug": "musikpool",
+                            "topicIds": [],
+                            "typeId": 3,
+                        },
+                        {
+                            "categoryIds": [],
+                            "cbaSeriesId": None,
+                            "defaultPlaylistId": None,
+                            "description": "",
+                            "email": "",
+                            "fundingCategoryId": 1,
+                            "hostIds": [1],
+                            "id": 3,
+                            "imageId": None,
+                            "isActive": True,
+                            "isPublic": True,
+                            "languageIds": [],
+                            "links": [],
+                            "logoId": None,
+                            "musicFocusIds": [],
+                            "name": "ZWEI",
+                            "predecessorId": None,
+                            "shortDescription": "deep work",
+                            "slug": "zwei",
+                            "topicIds": [],
+                            "typeId": 5,
+                        },
+                    ],
+                    "timeslots": [
+                        {
+                            "playlistId": None,
+                            "repetitionOfId": None,
+                            "end": "2024-07-22T18:00:00-04:00",
+                            "id": 146,
+                            "noteId": 146,
+                            "scheduleId": 2,
+                            "showId": 3,
+                            "start": "2024-07-22T15:30:00-04:00",
+                        },
+                        {
+                            "playlistId": None,
+                            "repetitionOfId": None,
+                            "end": "2024-07-22T23:00:00-04:00",
+                            "id": 267,
+                            "noteId": 267,
+                            "scheduleId": 3,
+                            "showId": 4,
+                            "start": "2024-07-22T20:30:00-04:00",
+                        },
+                        {
+                            "playlistId": None,
+                            "repetitionOfId": None,
+                            "end": "2024-07-23T12:30:00-04:00",
+                            "id": 16,
+                            "noteId": 16,
+                            "scheduleId": 1,
+                            "showId": 2,
+                            "start": "2024-07-23T10:00:00-04:00",
+                        },
+                    ],
+                    "profiles": [
+                        {
+                            "biography": "",
+                            "email": "",
+                            "id": 1,
+                            "imageId": None,
+                            "isActive": True,
+                            "links": [],
+                            "name": "Ernesto",
+                        }
+                    ],
+                    "categories": [
+                        {
+                            "description": "",
+                            "id": 5,
+                            "isActive": True,
+                            "name": "Mehr-/Fremdsprachig",
+                            "slug": "mehr-fremdsprachig",
+                            "subtitle": "",
+                        }
+                    ],
+                    "fundingCategories": [
+                        {"id": 1, "isActive": True, "name": "Standard", "slug": "standard"}
+                    ],
+                    "types": [
+                        {
+                            "id": 5,
+                            "isActive": True,
+                            "name": "Experimentell",
+                            "slug": "experimentell",
+                        },
+                        {
+                            "id": 2,
+                            "isActive": True,
+                            "name": "Musiksendung",
+                            "slug": "musiksendung",
+                        },
+                        {
+                            "id": 3,
+                            "isActive": True,
+                            "name": "Unmoderiertes Musikprogramm",
+                            "slug": "unmoderiertes-musikprogramm",
+                        },
+                    ],
+                    "images": [],
+                    "topics": [
+                        {
+                            "id": 5,
+                            "isActive": True,
+                            "name": "Wissenschaft/Philosophie",
+                            "slug": "wissenschaft-philosophie",
+                        }
+                    ],
+                    "languages": [
+                        {"id": 1, "isActive": True, "name": "Deutsch"},
+                        {"id": 3, "isActive": True, "name": "Spanisch"},
+                    ],
+                    "musicFocuses": [
+                        {"id": 4, "isActive": True, "name": "Rock/Indie", "slug": "rock-indie"}
+                    ],
+                    "program": [
+                        {
+                            "id": "2024-07-22T15:26:44.738502+00:00...2024-07-22T19:30:00+00:00",
+                            "start": "2024-07-22T11:26:44.738502-04:00",
+                            "end": "2024-07-22T15:30:00-04:00",
+                            "timeslotId": None,
+                            "playlistId": None,
+                            "showId": 1,
+                        },
+                        {
+                            "id": "2024-07-22T19:30:00+00:00...2024-07-22T22:00:00+00:00",
+                            "start": "2024-07-22T15:30:00-04:00",
+                            "end": "2024-07-22T18:00:00-04:00",
+                            "timeslotId": 146,
+                            "playlistId": None,
+                            "showId": 3,
+                        },
+                        {
+                            "id": "2024-07-22T22:00:00+00:00...2024-07-23T00:30:00+00:00",
+                            "start": "2024-07-22T18:00:00-04:00",
+                            "end": "2024-07-22T20:30:00-04:00",
+                            "timeslotId": None,
+                            "playlistId": None,
+                            "showId": 1,
+                        },
+                        {
+                            "id": "2024-07-23T00:30:00+00:00...2024-07-23T03:00:00+00:00",
+                            "start": "2024-07-22T20:30:00-04:00",
+                            "end": "2024-07-22T23:00:00-04:00",
+                            "timeslotId": 267,
+                            "playlistId": None,
+                            "showId": 4,
+                        },
+                        {
+                            "id": "2024-07-23T03:00:00+00:00...2024-07-23T14:00:00+00:00",
+                            "start": "2024-07-22T23:00:00-04:00",
+                            "end": "2024-07-23T10:00:00-04:00",
+                            "timeslotId": None,
+                            "playlistId": None,
+                            "showId": 1,
+                        },
+                        {
+                            "id": "2024-07-23T14:00:00+00:00...2024-07-23T16:30:00+00:00",
+                            "start": "2024-07-23T10:00:00-04:00",
+                            "end": "2024-07-23T12:30:00-04:00",
+                            "timeslotId": 16,
+                            "playlistId": None,
+                            "showId": 2,
+                        },
+                        {
+                            "id": "2024-07-23T16:30:00+00:00...2024-07-23T15:26:44.738502+00:00",
+                            "start": "2024-07-23T12:30:00-04:00",
+                            "end": "2024-07-23T11:26:44.738502-04:00",
+                            "timeslotId": None,
+                            "playlistId": None,
+                            "showId": 1,
+                        },
+                    ],
+                    "episodes": [
+                        {
+                            "cbaId": None,
+                            "content": "",
+                            "contributorIds": [],
+                            "id": 146,
+                            "imageId": None,
+                            "languageIds": [],
+                            "links": [],
+                            "summary": "",
+                            "tags": [],
+                            "timeslotId": 146,
+                            "title": "",
+                            "topicIds": [],
+                        },
+                        {
+                            "cbaId": None,
+                            "content": "",
+                            "contributorIds": [],
+                            "id": 267,
+                            "imageId": None,
+                            "languageIds": [],
+                            "links": [],
+                            "summary": "",
+                            "tags": [],
+                            "timeslotId": 267,
+                            "title": "",
+                            "topicIds": [],
+                        },
+                        {
+                            "cbaId": None,
+                            "content": "",
+                            "contributorIds": [],
+                            "id": 16,
+                            "imageId": None,
+                            "languageIds": [],
+                            "links": [],
+                            "summary": "",
+                            "tags": [],
+                            "timeslotId": 16,
+                            "title": "",
+                            "topicIds": [],
+                        },
+                    ],
+                    "licenses": [],
+                    "linkTypes": [
+                        {"id": 2, "isActive": True, "name": "CBA"},
+                        {"id": 11, "isActive": True, "name": "Facebook"},
+                        {"id": 3, "isActive": True, "name": "Freie Radios Online"},
+                        {"id": 4, "isActive": True, "name": "Funkwhale"},
+                        {"id": 10, "isActive": True, "name": "Instagram"},
+                        {"id": 7, "isActive": True, "name": "Internet Archive (archive.org)"},
+                        {"id": 13, "isActive": True, "name": "Mastodon"},
+                        {"id": 5, "isActive": True, "name": "Mixcloud"},
+                        {"id": 6, "isActive": True, "name": "SoundCloud"},
+                        {"id": 9, "isActive": True, "name": "Spotify"},
+                        {"id": 12, "isActive": True, "name": "Twitter"},
+                        {"id": 1, "isActive": True, "name": "Website"},
+                        {"id": 8, "isActive": True, "name": "YouTube"},
+                    ],
+                },
             )
-
-        include_virtual = request.GET.get("include_virtual") == "true"
-
-        playout = get_timerange_timeslot_entries(schedule_start, schedule_end, include_virtual)
-
-        return JsonResponse(camelize(playout), safe=False)
+        ],
+        summary=(
+            "List program for a specific date range. "
+            "Returns all relevant data for the specified time frame."
+        ),
+        # FIXME:
+        #  This doesn’t work because the list wrapping behaviour is forced in drf-spectacular.
+        #  This can potentially be fixed with an OpenApiSerializerExtension.
+        #  see: https://drf-spectacular.readthedocs.io/en/latest/customization.html#declare-serializer-magic-with-openapiserializerextension  # noqa: E501
+        responses=serializer_class(many=False),
+    )
+    def list(self, request, *args, **kwargs):
+        program = list(self.filter_queryset(self.get_queryset()))
+        serializer = self.get_serializer(instance=CalendarSchemaSerializer.Wrapper(program))
+        return Response(serializer.data)
 
 
 @extend_schema_view(
diff --git a/steering/urls.py b/steering/urls.py
index e807f164d7e3d12f60948017bce616e49d2eb56f..ccf2a0d0f7a65d60e2504ab54b8a5235d3ea6f1c 100644
--- a/steering/urls.py
+++ b/steering/urls.py
@@ -34,9 +34,10 @@ from program.views import (
     APILinkTypeViewSet,
     APIMusicFocusViewSet,
     APINoteViewSet,
-    APIPlayoutViewSet,
     APIProfileViewSet,
-    APIProgramViewSet,
+    APIProgramBasicViewSet,
+    APIProgramCalendarViewSet,
+    APIProgramPlayoutViewSet,
     APIRadioSettingsViewSet,
     APIRRuleViewSet,
     APIScheduleViewSet,
@@ -67,17 +68,13 @@ router.register(r"link-types", APILinkTypeViewSet, basename="link-type")
 router.register(r"rrules", APIRRuleViewSet, basename="rrule")
 router.register(r"images", APIImageViewSet, basename="image")
 router.register(r"settings", APIRadioSettingsViewSet, basename="settings")
-router.register(r"playout", APIPlayoutViewSet, basename="playout")
-router.register(r"program/week", APIPlayoutViewSet, basename="program/week")
+router.register(r"program/basic", APIProgramBasicViewSet, basename="program-basic")
+router.register(r"program/playout", APIProgramPlayoutViewSet, basename="program-playout")
+router.register(r"program/calendar", APIProgramCalendarViewSet, basename="program-calendar")
 
 urlpatterns = [
     path("openid/", include("oidc_provider.urls", namespace="oidc_provider")),
     path("api/v1/", include(router.urls)),
-    path(
-        "api/v1/program/<int:year>/<int:month>/<int:day>/",
-        APIProgramViewSet.as_view({"get": "list"}),
-        name="program",
-    ),
     path("api/v1/schema/", SpectacularAPIView.as_view(), name="schema"),
     path(
         "api/v1/schema/swagger-ui/",