Room booking

Six rooms, a diary, and a form that writes to the CMS.

Pick a room and a date. The hours already taken come back from the bookings collection; the rest are yours. Choose one, fill the form in, and press the button - the booking is written to this CMS and appears in the diary underneath, because both read the same array.

Go on. It really writes.

Live API call /api/v1/bookings

Checking the diary…

Room

· up to people

Your booking

Booked

Written to the bookings collection through POST /api/v1/bookings. Open the CMS admin and it is there, with the room resolved by reference.

Everything booked that day

  • 09:00 Taken

Names and email addresses are not here because the collection's read allow-list does not include them - the public API cannot return them however the request is phrased.

What is actually going on

Six pieces of state are genuinely input: the room, the date, the diary that came back, the chosen hour, and the form's fields. Everything else on that page is derived. Which hours are free, how long you may book for, what it costs, which validation messages apply, whether the button is enabled, what the button says - all computed, all recalculated by reading, none of them updated by hand.

That is the property worth having. There is no code path in this demo that can leave the price disagreeing with the duration, or the button enabled while a message says the form is invalid, because those are not two states that have to be kept in sync - they are two readings of one state.

Availability is a computed, not a routine

apps/bookings.js - working out the free hoursview whole file
/**
 * The hours the room is open, each marked with whether something already
 * covers it.
 *
 * This reads `room` and `dayBookings` and nothing else, so it recomputes
 * when the room changes or when the diary comes back from the API - and
 * sits still when you type your name into the form. The list is rebuilt
 * wholesale on purpose: `data-each="slots key=hour"` reconciles by key, so
 * the buttons that are still there keep their actual DOM nodes.
 */
const slots = computed(() => {
    const current = room.value;
    if (!current) return [];

    const taken = new Set();
    for (const booking of dayBookings.value) {
        if (booking.status === 'cancelled') continue;
        for (let h = 0; h < Number(booking.hours || 1); h++) {
            taken.add(Number(booking.startHour) + h);
        }
    }

    const out = [];
    for (let hour = Number(current.openHour); hour < Number(current.closeHour); hour++) {
        out.push({hour, label: clock(hour), taken: taken.has(hour)});
    }
    return out;
});

It reads the room and the diary, so it recalculates when either changes and sits still while you type your name. The array is rebuilt wholesale on purpose: data-each="slots key=hour" reconciles by key, so the buttons that are still there keep their actual DOM nodes and nothing flickers.

The only place it talks to the server

apps/bookings.js - one effect, two dependenciesview whole file
/**
 * The only place this app talks to the server, and it is an effect.
 *
 * `roomId` and `date` are read synchronously, before the first `await` -
 * that is what makes them this effect's dependencies. Change either and the
 * diary is refetched; change anything else and nothing happens here.
 *
 * The request is filtered server-side through the CMS filter DSL, so the
 * page asks for one room on one day rather than fetching the diary and
 * sifting it in the browser.
 */
let requestToken = 0;

effect(() => {
    const currentRoom = roomId.value;
    const currentDate = date.value;
    if (!currentRoom || !currentDate) return;

    const query = {filter: {roomId: currentRoom, date: currentDate}, limit: 100, sort: 'startHour', order: 'asc'};
    lastQuery.value = `GET /api/v1/bookings${buildQuery(query)}`;

    // A stale response must never overwrite a fresh one - the room can
    // change while a request is in flight.
    const token = ++requestToken;
    loading.value = true;
    error.value = '';

    bookings.list(query)
        .then(({items}) => {
            if (token !== requestToken) return;
            dayBookings.set(items);
            selectedHour.value = null;
        })
        .catch(err => {
            if (token !== requestToken) return;
            error.value = `Could not load the diary: ${err.message}`;
        })
        .finally(() => {
            if (token === requestToken) loading.value = false;
        });
});

roomId and date are read synchronously, before the first await. That is what makes them this effect's dependencies - and it is the one rule to remember when an effect does asynchronous work. Change the room and the diary is refetched; type in the form and nothing here runs.

The filter goes to the server rather than to the browser: filter[roomId]=…&filter[date]=…. The page asks for one room on one day. Watch the Live API call line above change as you use the form; that is the real URL, updated by the same effect that makes the request.

Validation as derived state

apps/bookings.js - problems, and readinessview whole file
/**
 * Validation as derived state rather than as a step someone remembers to
 * run. The list of problems is a `computed`; the button's disabled state is
 * a `computed` over that. There is no "validate()" to call at the wrong
 * moment, and no way for the message list and the button to disagree.
 */
const problems = computed(() => {
    const out = [];
    if (name.value.trim().length < 2) out.push('We need a name to put the booking under.');
    if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email.value)) out.push('That email address does not look right.');
    if (Number(people.value) < 1) out.push('One person minimum.');
    if (room.value && Number(people.value) > Number(room.value.capacity)) {
        out.push(`${room.value.name} holds ${room.value.capacity}. Try a bigger room.`);
    }
    if (Number(hours.value) > maxHours.value) out.push('That runs into a booking someone else has.');
    return out.map((text, id) => ({id, text}));
});

const ready = computed(() => problems.value.length === 0 && !saving.value);

No validate() to call at the wrong moment. The list of problems is a computed; the button's disabled state is a computed over that list. The capacity check reads the room straight out of the CMS - book more people than the Glasshouse holds and it tells you which room to try instead.

Writing

apps/bookings.js - POST /api/v1/bookingsview whole file
/**
 * Writing. `POST /api/v1/bookings` with the fields the schema declares -
 * the CMS validates the required ones, checks that `roomId` really is a
 * room, and stores the entry. The new booking is pushed into the same
 * array the diary and the slot grid read, so both update from one write.
 */
async function save() {
    if (!ready.value) return;
    saving.value = true;
    error.value = '';

    try {
        const created = await bookings.create({
            roomId: roomId.value,
            date: date.value,
            startHour: selectedHour.value,
            hours: Number(hours.value),
            name: name.value.trim(),
            email: email.value.trim(),
            people: Number(people.value),
            notes: notes.value.trim(),
            status: 'pending'
        });

        dayBookings.push({...created, id: created.id});

        confirmation.value = {
            line: `${clock(selectedHour.value)} for ${hours.value === 1 ? 'an hour' : `${hours.value} hours`} `
                + `in the ${room.value.name}, ${dayLabel.value}. ${money(total.value)} on the day.`
        };

        selectedHour.value = null;
        notes.value = '';
        window.E.toast('Booking saved to the CMS.', {type: 'success'});
    } catch (err) {
        error.value = err.message || 'The booking was refused.';
        window.E.toast(error.value, {type: 'error'});
    } finally {
        saving.value = false;
    }
}

The CMS does the validating that matters: required fields, and that roomId is genuinely a room. Post a booking with a made-up room and it comes back refused, with the reason:

{"error": "Validation failed: \"Room\" → no entry \"nope\" in \"rooms\""}

The created booking is pushed into the same observableArray the slot grid and the diary read, so one write updates both without either being told about it.

The photograph is a field, not a feature

The rooms collection carries an image - a path under /media - and an imageCredit next to it. There is no image handling in this demo: the room entry arrives from GET /api/v1/rooms with that string already on it, and the markup binds it the way it binds the price.

<figure class="room-photo" data-if="room.value.image">
    <img data-bind-src="room.value.image" data-bind-alt="room.value.name">
    <figcaption data-bind-text="room.value.imageCredit"></figcaption>
</figure>

data-bind-src is not a second kind of binding. data-bind-<name> writes a property when the DOM has one and an attribute when it does not, so the same handler behind data-bind-text puts this src on the image - which is why changing the room in the picker changes the photograph without a line of code saying so.

Privacy, without writing any code for it

The diary shows you that 10:00 is taken and how many people are coming. It does not show you who booked it - and not because the template leaves the name out. The name never arrives:

"read": {
  "enabled": true,
  "access": "public",
  "fields": ["roomId", "date", "startHour", "hours", "people", "status"]
}

name, email and notes are stored on every booking and are absent from that allow-list, so no public read can return them however the request is phrased. Try it yourself - the API is open:

curl 'https://reactive.dommajs.org/api/v1/bookings?filter[date]=2026-08-24'

The whole thing

Two files. The markup, exactly as this page renders it, and the module that activates it.

apps/bookings.jsview whole file
/**
 * Room booking - a real CMS collection, read and written from the page.
 *
 * Three Domma CMS collections do all the storage: `rooms` (published read-only
 * over the API), `bookings` (public read *and* public create), and the
 * reference between them, which the CMS validates on write. There is no
 * back-end code behind this demo. There is no endpoint written for it. The
 * schemas say what may be read and by whom, and this file talks to the result.
 *
 * The reactive part is the interesting part: the room, the date and the
 * bookings that came back are the only state here. Everything the page shows -
 * which hours are free, how long you may book for, what it will cost, whether
 * the form may be submitted - is a `computed` over those, so no piece of this
 * can show you an answer that disagrees with another piece.
 */

import {
    observable, observableArray, computed, effect, applyBindings
} from '../domma-reactive.esm.js';

import {collection, money, buildQuery} from '../cms.js';

const rooms = collection('rooms');
const bookings = collection('bookings');

/** Today, as the `<input type="date">` wants it. */
function today() {
    return new Date().toISOString().slice(0, 10);
}

/** 9 → "09:00" */
function clock(hour) {
    return `${String(hour).padStart(2, '0')}:00`;
}

export async function mount(element) {
    // ---------------------------------------------------------------- state
    // Everything that is genuinely input. Six cells; the rest is derived.
    const roomList = observableArray([]);
    const roomId = observable('');
    const date = observable(today());
    const dayBookings = observableArray([]);
    const selectedHour = observable(null);
    const loading = observable(false);
    const error = observable('');
    const lastQuery = observable('');

    const hours = observable(1);
    const people = observable(2);
    const name = observable('');
    const email = observable('');
    const notes = observable('');
    const naming = observable(false);
    const saving = observable(false);
    const confirmation = observable(null);

    // ------------------------------------------------------------- derived
    const room = computed(() => roomList.value.find(r => r.id === roomId.value) || null);

    const features = computed(() => String(room.value?.features || '')
        .split(',')
        .map(s => s.trim())
        .filter(Boolean)
        .map((label, id) => ({id, label})));

    /**
     * The hours the room is open, each marked with whether something already
     * covers it.
     *
     * This reads `room` and `dayBookings` and nothing else, so it recomputes
     * when the room changes or when the diary comes back from the API - and
     * sits still when you type your name into the form. The list is rebuilt
     * wholesale on purpose: `data-each="slots key=hour"` reconciles by key, so
     * the buttons that are still there keep their actual DOM nodes.
     */
    const slots = computed(() => {
        const current = room.value;
        if (!current) return [];

        const taken = new Set();
        for (const booking of dayBookings.value) {
            if (booking.status === 'cancelled') continue;
            for (let h = 0; h < Number(booking.hours || 1); h++) {
                taken.add(Number(booking.startHour) + h);
            }
        }

        const out = [];
        for (let hour = Number(current.openHour); hour < Number(current.closeHour); hour++) {
            out.push({hour, label: clock(hour), taken: taken.has(hour)});
        }
        return out;
    });

    /** How many consecutive free hours start at the chosen one, capped at four. */
    const maxHours = computed(() => {
        if (selectedHour.value === null) return 0;
        const free = new Map(slots.value.map(s => [s.hour, !s.taken]));
        let run = 0;
        while (run < 4 && free.get(selectedHour.value + run)) run++;
        return run;
    });

    const durations = computed(() => Array.from({length: maxHours.value}, (_, i) => ({
        hours: i + 1,
        label: i === 0 ? '1 hour' : `${i + 1} hours`
    })));

    const total = computed(() => Number(room.value?.pricePerHour || 0) * Number(hours.value || 0));

    /**
     * Validation as derived state rather than as a step someone remembers to
     * run. The list of problems is a `computed`; the button's disabled state is
     * a `computed` over that. There is no "validate()" to call at the wrong
     * moment, and no way for the message list and the button to disagree.
     */
    const problems = computed(() => {
        const out = [];
        if (name.value.trim().length < 2) out.push('We need a name to put the booking under.');
        if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email.value)) out.push('That email address does not look right.');
        if (Number(people.value) < 1) out.push('One person minimum.');
        if (room.value && Number(people.value) > Number(room.value.capacity)) {
            out.push(`${room.value.name} holds ${room.value.capacity}. Try a bigger room.`);
        }
        if (Number(hours.value) > maxHours.value) out.push('That runs into a booking someone else has.');
        return out.map((text, id) => ({id, text}));
    });

    const ready = computed(() => problems.value.length === 0 && !saving.value);

    const diary = computed(() => [...dayBookings.value]
        .sort((a, b) => a.startHour - b.startHour)
        .map(b => ({
            id: b.id,
            when: `${clock(b.startHour)} - ${clock(Number(b.startHour) + Number(b.hours))}`,
            // No name here, and not by omission: `name` and `email` are absent
            // from the collection's read allow-list, so the API never sent them.
            who: `${b.people} ${Number(b.people) === 1 ? 'person' : 'people'}`,
            status: b.status
        })));

    // Labels, so the template holds no formatting logic of its own.
    const priceLabel = computed(() => `${money(room.value?.pricePerHour || 0)} an hour`);
    const dayLabel = computed(() => window.D(date.value).format('dddd D MMMM'));
    const freeLabel = computed(() => {
        const free = slots.value.filter(s => !s.taken).length;
        return `${free} of ${slots.value.length} hours free`;
    });
    const chosenLabel = computed(() => selectedHour.value === null
        ? 'Your booking'
        : `${clock(selectedHour.value)} in the ${room.value?.name}`);
    const totalLabel = computed(() => `${hours.value} × ${money(room.value?.pricePerHour || 0)} = ${money(total.value)}`);
    const submitLabel = computed(() => (saving.value ? 'Booking…' : `Book ${hours.value === 1 ? 'the hour' : `${hours.value} hours`}`));

    /**
     * The only place this app talks to the server, and it is an effect.
     *
     * `roomId` and `date` are read synchronously, before the first `await` -
     * that is what makes them this effect's dependencies. Change either and the
     * diary is refetched; change anything else and nothing happens here.
     *
     * The request is filtered server-side through the CMS filter DSL, so the
     * page asks for one room on one day rather than fetching the diary and
     * sifting it in the browser.
     */
    let requestToken = 0;

    effect(() => {
        const currentRoom = roomId.value;
        const currentDate = date.value;
        if (!currentRoom || !currentDate) return;

        const query = {filter: {roomId: currentRoom, date: currentDate}, limit: 100, sort: 'startHour', order: 'asc'};
        lastQuery.value = `GET /api/v1/bookings${buildQuery(query)}`;

        // A stale response must never overwrite a fresh one - the room can
        // change while a request is in flight.
        const token = ++requestToken;
        loading.value = true;
        error.value = '';

        bookings.list(query)
            .then(({items}) => {
                if (token !== requestToken) return;
                dayBookings.set(items);
                selectedHour.value = null;
            })
            .catch(err => {
                if (token !== requestToken) return;
                error.value = `Could not load the diary: ${err.message}`;
            })
            .finally(() => {
                if (token === requestToken) loading.value = false;
            });
    });

    // Keep the requested length inside what is actually free.
    effect(() => {
        if (hours.value > maxHours.value) hours.value = Math.max(1, maxHours.value);
    });

    /**
     * Writing. `POST /api/v1/bookings` with the fields the schema declares -
     * the CMS validates the required ones, checks that `roomId` really is a
     * room, and stores the entry. The new booking is pushed into the same
     * array the diary and the slot grid read, so both update from one write.
     */
    async function save() {
        if (!ready.value) return;
        saving.value = true;
        error.value = '';

        try {
            const created = await bookings.create({
                roomId: roomId.value,
                date: date.value,
                startHour: selectedHour.value,
                hours: Number(hours.value),
                name: name.value.trim(),
                email: email.value.trim(),
                people: Number(people.value),
                notes: notes.value.trim(),
                status: 'pending'
            });

            dayBookings.push({...created, id: created.id});

            confirmation.value = {
                line: `${clock(selectedHour.value)} for ${hours.value === 1 ? 'an hour' : `${hours.value} hours`} `
                    + `in the ${room.value.name}, ${dayLabel.value}. ${money(total.value)} on the day.`
            };

            selectedHour.value = null;
            notes.value = '';
            window.E.toast('Booking saved to the CMS.', {type: 'success'});
        } catch (err) {
            error.value = err.message || 'The booking was refused.';
            window.E.toast(error.value, {type: 'error'});
        } finally {
            saving.value = false;
        }
    }

    // ------------------------------------------------------------ view model
    const app = {
        rooms: roomList, roomId, date, loading, error, lastQuery,
        room, features, slots, durations, diary, problems, ready,
        selectedHour, hours, people, name, email, notes, naming, confirmation,
        priceLabel, dayLabel, freeLabel, chosenLabel, totalLabel, submitLabel,

        choose(slot) {
            if (slot.taken) return;
            selectedHour.value = slot.hour;
            hours.value = 1;
            naming.value = name.value.trim() === '';   // caret into the first empty field
            confirmation.value = null;
        },

        // A DOM event fires outside every effect, which is why an expression may
        // call a method here and nowhere else. Returning false calls
        // preventDefault(), so the form never navigates.
        submit() {
            save();
            return false;
        }
    };

    applyBindings(app, element);

    // The room list arrives after the bindings are live, which is the normal
    // case rather than a special one: `data-options` rebuilds and `data-model`
    // re-applies the selection it was waiting for.
    try {
        const [{items: roomItems}, {items: soonest}] = await Promise.all([
            rooms.list({limit: 50, sort: 'name', order: 'asc'}),
            // The next day that already has something in the diary, so the demo
            // opens on a day with something to see. `_gte` is the same filter
            // DSL the search above uses.
            bookings.list({filter: {date_gte: today()}, sort: 'date', order: 'asc', limit: 1})
        ]);

        roomList.set(roomItems);

        const opening = soonest[0];
        roomId.value = opening?.roomId || roomItems[0]?.id || '';
        if (opening?.date) date.value = opening.date;
    } catch (err) {
        error.value = `Could not load the rooms: ${err.message}`;
    }
}
markup/bookings.htmlview whole file
<div class="app app--bookings">

    <div class="app-bar">
        <label class="form-label">Room
            <select class="form-select" data-model="roomId.value" data-options="rooms.value" data-options-text="name" data-options-value="id"></select>
        </label>

        <label class="form-label">Date
            <input class="form-input" type="date" data-model="date.value">
        </label>

        <p class="app-query">
            <span class="app-query-label">Live API call</span>
            <code data-bind-text="lastQuery.value">/api/v1/bookings</code>
        </p>
    </div>

    <p class="app-note" data-if="loading.value">Checking the diary&hellip;</p>
    <p class="app-error" data-if="error.value" data-bind-text="error.value"></p>

    <section class="room" data-if="room.value" data-bind-style---accent="room.value.accent">
        <header class="room-head">
            <h3 data-bind-text="room.value.name">Room</h3>
            <p class="room-price"><b data-bind-text="priceLabel.value"></b> &middot; up to <span data-bind-text="room.value.capacity"></span> people</p>
        </header>

        <figure class="room-photo" data-if="room.value.image">
            <img data-bind-src="room.value.image" data-bind-alt="room.value.name"
                 width="1200" height="750" loading="lazy">
            <figcaption data-bind-text="room.value.imageCredit"></figcaption>
        </figure>

        <p class="room-summary" data-bind-text="room.value.summary"></p>

        <ul class="chips" data-each="features key=id">
            <li class="chip" data-bind-text="label"></li>
        </ul>

        <h4 class="slots-title">
            <span data-bind-text="dayLabel.value"></span>
            <small data-bind-text="freeLabel.value"></small>
        </h4>

        <ol class="slots" data-each="slots key=hour">
            <li>
                <button type="button" class="slot"
                        data-on-click="$parent.choose($data)"
                        data-bind-disabled="taken"
                        data-bind-class="taken ? 'is-taken' : ($parent.selectedHour.value === hour ? 'is-chosen' : '')">
                    <span class="slot-time" data-bind-text="label">09:00</span>
                    <small class="slot-state" data-bind-text="taken ? 'Taken' : 'Free'">Free</small>
                </button>
            </li>
        </ol>
    </section>

    <form class="booking" data-if="selectedHour.value !== null" data-on-submit="submit">
        <h4 data-bind-text="chosenLabel.value">Your booking</h4>

        <div class="booking-grid">
            <label class="form-label">How long
                <select class="form-select" data-model="hours.value" data-options="durations.value" data-options-text="label" data-options-value="hours"></select>
            </label>

            <label class="form-label">People
                <input class="form-input" type="number" min="1" data-model="people.value">
            </label>

            <label class="form-label">Your name
                <input class="form-input" data-model="name.value" data-focus="naming.value" placeholder="Ada Lovelace">
            </label>

            <label class="form-label">Email
                <input class="form-input" type="email" data-model="email.value" placeholder="ada@example.com">
            </label>

            <label class="form-label form-label--wide">Anything we should know?
                <textarea class="form-textarea" rows="2" data-model="notes.value"></textarea>
            </label>
        </div>

        <ul class="problems" data-each="problems key=id">
            <li data-bind-text="text"></li>
        </ul>

        <div class="booking-foot">
            <p class="booking-total" data-bind-text="totalLabel.value"></p>
            <button class="btn btn-primary" type="submit" data-bind-disabled="!ready.value" data-bind-text="submitLabel.value">Book it</button>
        </div>
    </form>

    <section class="confirmed" data-if="confirmation.value">
        <h4>Booked</h4>
        <p data-bind-text="confirmation.value.line"></p>
        <p class="app-note">Written to the <code>bookings</code> collection through <code>POST /api/v1/bookings</code>. Open the CMS admin and it is there, with the room resolved by reference.</p>
    </section>

    <section class="diary" data-if="diary.value.length">
        <h4>Everything booked that day</h4>
        <ul class="diary-list" data-each="diary key=id">
            <li>
                <b data-bind-text="when">09:00</b>
                <span data-bind-text="who">Taken</span>
                <em data-bind-text="status"></em>
            </li>
        </ul>
        <p class="app-note">Names and email addresses are not here because the collection's read allow-list does not include them - the public API cannot return them however the request is phrased.</p>
    </section>

</div>
cms.js - shared by both demosview whole file
/**
 * The Domma CMS collection API, as the demos use it.
 *
 * Every collection on this site (`rooms`, `bookings`, `properties`, `viewings`)
 * is a folder of JSON managed in the CMS admin. Turning on `api.read.access =
 * "public"` in its schema publishes it at `/api/v1/<slug>` - no code, no
 * endpoint to write. That is what these functions talk to.
 *
 * HTTP goes through Domma JS's `H` client rather than `fetch`, because on a
 * Domma CMS page `H` is already there and already does the JSON handling.
 */

const api = window.H;

/**
 * Turn a plain options object into the query string `/api/v1/:slug` accepts.
 *
 * The `filter` key is the interesting one. The CMS filter DSL takes
 * `filter[<field>_<op>]=<value>` with the operators `_eq _ne _gt _gte _lt _lte
 * _in _nin _contains _starts _ends _exists`, so a whole search form can be
 * expressed as data and sent to the server as one URL.
 *
 * @param {object} [opts]
 * @param {number} [opts.limit]   Page size (the API's own default is 50)
 * @param {string} [opts.sort]    Field to sort on
 * @param {'asc'|'desc'} [opts.order]
 * @param {string} [opts.search]  Free-text search across fields
 * @param {object} [opts.filter]  `{price_lte: 500000, town: 'Harrogate'}`
 * @param {boolean} [opts.resolveRefs] Resolve reference fields into `_refs`
 * @returns {string}
 */
export function buildQuery({limit, sort, order, search, filter, resolveRefs} = {}) {
    const params = new URLSearchParams();

    if (limit) params.set('limit', String(limit));
    if (sort) params.set('sort', sort);
    if (order) params.set('order', order);
    if (search) params.set('search', search);
    if (resolveRefs) params.set('resolveRefs', 'true');

    for (const [key, value] of Object.entries(filter || {})) {
        // An absent filter is one the visitor has not filled in - it must not
        // narrow the query at all, which is not the same as filtering on "".
        if (value === '' || value === null || value === undefined) continue;
        params.set(`filter[${key}]`, String(value));
    }

    const query = params.toString();
    return query ? `?${query}` : '';
}

/**
 * A handle on one published collection.
 *
 * @param {string} slug
 */
export function collection(slug) {
    const base = `/api/v1/${slug}`;

    return {
        /**
         * Read entries. The API returns `{id, data, meta}` records; these are
         * flattened to `{id, ...data}` because that is the shape a template
         * wants, and `id` is what `data-each key=id` reconciles on.
         *
         * @param {object} [opts] See {@link buildQuery}
         * @returns {Promise<{items: object[], total: number}>}
         */
        async list(opts) {
            const result = await api.get(base + buildQuery(opts));
            const entries = result.entries || [];
            return {
                items: entries.map(entry => ({id: entry.id, ...entry.data, _refs: entry._refs})),
                total: result.total ?? entries.length
            };
        },

        /**
         * Create an entry. The collection's schema decides whether this is
         * allowed and validates every field before anything is written -
         * `bookings` and `viewings` accept public writes, the rest do not.
         *
         * @param {object} data Field values keyed by field name
         * @returns {Promise<object>} The stored entry, flattened as above
         */
        async create(data) {
            const entry = await api.post(base, {data});
            return {id: entry.id, ...entry.data};
        }
    };
}

/** Money, the way a listing wants to read it. */
export function money(value, {decimals = 0} = {}) {
    return new Intl.NumberFormat('en-GB', {
        style: 'currency',
        currency: 'GBP',
        minimumFractionDigits: decimals,
        maximumFractionDigits: decimals
    }).format(Number(value) || 0);
}

/** A date, via Domma's `D()`, which is on the page already. */
export function formatDate(value, pattern = 'ddd D MMM YYYY') {
    return window.D(value).format(pattern);
}

Now the house hunter

The room photographs are CC0, by Unsplash contributors, via Wikimedia Commons. Who took which one.