Slate shares its shape with Pulse, Savor and Wayline: the module layout, accounts and sign-in, cards and the payment provider, text messages, push notifications, the live channel and hosting the app in a browser. This chapter covers what an appointment business changes: a slot engine that asks every party at once, rules about gaps, and calendars that are read and written in both directions.

Running it

The project is in scripts/slate. Start the server on the development profile, which seeds the demo salon and its accounts, simulates the cards and the online calendars:

cd scripts/slate
CN1_PROFILE=dev backend/server.sh run

Then run the app in the simulator:

cd scripts/slate
mvn verify -Psimulator -DskipTests -Dcodename1.platform=javase

The modules

shared holds the data classes and the @RestClient interfaces both sides build from. common is the app, for every platform. backend is the server, in packages by subject:

PackageWhat it holds

account

Accounts, phone verification, preferences and the demo accounts.

catalog

The business, its locations, services, providers, resources and verticals.

booking

The slot engine, availability, holds, bookings, rescheduling, the waitlist and the demo business.

provider

Working hours, time off and a provider’s schedule.

sync

Connected calendars: Google, Microsoft, published ICS addresses, the phone’s own calendars and the feed Slate publishes.

owner

The dashboard and the board of the day.

pay

Deposits and no-show fees: simulated or Stripe.

live, push, sms

The WebSocket, push notifications and text messages.

Roles are CLIENT, PROVIDER and OWNER, in Roles. Every account is a client; the others are granted by an owner, and the app offers a mode for each.

One business, many verticals

A salon books a stylist and a chair, a tutor a room, a clinic a practitioner and a treatment room. The rules and the screens are the same; only the words and the accent colour differ:

/// The kinds of business Slate is set up for, and what each calls things.
///
/// A vertical is words and a colour, nothing more. The booking rules, the
/// slot engine and every screen are the same for a salon and a consultancy;
/// what differs is that one books a stylist and a chair and the other a
/// consultant and a meeting room. A new vertical is a row here and, for the
/// demo, a set of services in `DemoBusiness`.
public final class Verticals {
    public static final String SALON = "SALON";
    public static final String TUTOR = "TUTOR";
    public static final String TRAINER = "TRAINER";
    public static final String CONSULTANT = "CONSULTANT";
    public static final String CLINIC = "CLINIC";

    public static final String[] ALL = {SALON, TUTOR, TRAINER, CONSULTANT, CLINIC};

    /// vertical, provider, providers, client, service, resource, accent
    private static final String[][] WORDS = {
        {SALON, "Stylist", "Stylists", "Client", "Treatment", "Chair", "#c2185b"},
        {TUTOR, "Tutor", "Tutors", "Student", "Lesson", "Room", "#3949ab"},
        {TRAINER, "Trainer", "Trainers", "Client", "Session", "Studio", "#00897b"},
        {CONSULTANT, "Consultant", "Consultants", "Client", "Meeting", "Meeting room",
            "#455a64"},
        {CLINIC, "Practitioner", "Practitioners", "Patient", "Appointment", "Treatment room",
            "#1e88e5"},
    };

The slot engine

SlotEngine decides which starts can be booked. A start is offered only when every party the service needs is free — the providers, one resource of the right kind, and the client — and every rule the business set holds: notice and horizon, buffers before and after, a provider’s daily cap and breaks, travel time between locations, the client’s gap between bookings and their bookings per day, and the shortest gap worth leaving on a provider’s day. It reads nothing and writes nothing, which is what lets each rule be tested by itself:

/// Decides which start times can be booked: every party a booking needs has to
/// be free, and every rule the business set has to hold.
///
/// It reads nothing and writes nothing. What it is given -- the start times to
/// try, each provider's shifts and busy time, the resources, the client's own
/// day -- is assembled by [Availability] from the database, and the same
/// decision is made again by [Bookings] for the one start a client picks, just
/// before the time is claimed. Keeping it pure is what lets the rules be
/// tested one at a time, and lets the owner's preview say, for every start and
/// every provider, which rule turned it down.
///
/// Times are milliseconds since the epoch. The engine knows nothing of zones:
/// the start times it is handed were already chosen on the local clock, and a
/// shift is already the two instants it runs between, so a day that changes to
/// or from daylight saving is the caller's to get right, once.
public final class SlotEngine {
    /// The grid every duration and every buffer is a multiple of, and the
    /// size of one [com.codenameone.examples.slate.domain.SlotClaim].
    public static final int CELL_MINUTES = 5;
    public static final long MINUTE = 60000L;
    public static final long CELL = CELL_MINUTES * MINUTE;

    /// Why a start was turned down. [#OK] is the one that was not.
    public static final String OK = "OK";
    public static final String TOO_SOON = "TOO_SOON";
    public static final String TOO_FAR = "TOO_FAR";
    public static final String OUTSIDE_HOURS = "OUTSIDE_HOURS";
    public static final String BOOKED = "BOOKED";
    public static final String TIME_OFF = "TIME_OFF";
    public static final String CALENDAR = "CALENDAR";
    public static final String TRAVEL = "TRAVEL";
    public static final String DAY_FULL = "DAY_FULL";
    public static final String NEEDS_BREAK = "NEEDS_BREAK";
    public static final String LEAVES_GAP = "LEAVES_GAP";
    public static final String NOT_ADJACENT = "NOT_ADJACENT";
    public static final String NO_RESOURCE = "NO_RESOURCE";
    public static final String CLIENT_BUSY = "CLIENT_BUSY";
    public static final String CLIENT_GAP = "CLIENT_GAP";
    public static final String CLIENT_DAY_FULL = "CLIENT_DAY_FULL";
    public static final String GROUP_FULL = "GROUP_FULL";
    public static final String NOT_ENOUGH_PROVIDERS = "NOT_ENOUGH_PROVIDERS";

    /// What kind of time a [Busy] is.
    public static final String KIND_APPOINTMENT = "APPOINTMENT";
    public static final String KIND_TIME_OFF = "TIME_OFF";
    public static final String KIND_CALENDAR = "CALENDAR";

    private SlotEngine() {
    }

    /// The business's rules, with the service's own values already applied.
    public static final class Rules {
        public long now;
        public int minNoticeMinutes;
        public int horizonDays;
        public int clientGapMinutes;
        public int clientPerDay;
        public int minUsableGapMinutes;
        public boolean tight;
    }

    /// What the service needs.
    public static final class Need {
        public String serviceId = "";
        public String locationId = "";
        public int minutes;
        public int bufferBefore;
        public int bufferAfter;
        public int capacity = 1;
        public int providersNeeded = 1;
        public String resourceKind = "";
    }

    /// Time that is taken. `locationId` is where an appointment is, for the
    /// travel rule; `groupKey` is the group slot it is a seat of, which a
    /// further seat of the same slot does not count as busy.
    public static final class Busy {
        public final long start;
        public final long end;
        public final String kind;
        public final String locationId;
        public final String groupKey;

        public Busy(long start, long end, String kind, String locationId, String groupKey) {
            this.start = start;
            this.end = end;
            this.kind = kind;
            this.locationId = locationId == null ? "" : locationId;
            this.groupKey = groupKey == null ? "" : groupKey;
        }
    }

    /// One stretch of a provider's working day, at one location.
    public static final class Shift {
        public final long start;
        public final long end;
        public final String locationId;

        public Shift(long start, long end, String locationId) {
            this.start = start;
            this.end = end;
            this.locationId = locationId;
        }
    }

    /// A provider as the engine sees them for one day.
    public static final class Party {
        public String id = "";
        public final List<Shift> shifts = new ArrayList<Shift>();
        public final List<Busy> busy = new ArrayList<Busy>();
        /// Appointments already on the day the starts fall in.
        public int appointmentsToday;
        public int maxPerDay;
        public int maxContinuousMinutes;
        public int breakMinutes;
    }

    /// A resource at the location, with the time it is taken.
    public static final class Res {
        public String id = "";
        public final List<Busy> busy = new ArrayList<Busy>();
    }

    /// Everything one evaluation needs.
    public static final class Request {
        public long[] starts = new long[0];
        public Rules rules = new Rules();
        public Need need = new Need();
        public final List<Party> providers = new ArrayList<Party>();
        /// Providers the client asked for by name; every one of them has to be
        /// free. Empty for whoever is.
        public final List<String> required = new ArrayList<String>();
        public final List<Res> resources = new ArrayList<Res>();
        /// The client's own appointments around the day, of any service.
        public final List<Busy> client = new ArrayList<Busy>();
        public int clientAppointmentsToday;
        /// Seats already taken in each group slot, by group key.
        public final Map<String, Integer> groupSeats = new HashMap<String, Integer>();
        /// Asks for a verdict per start and provider, for the owner's preview.
        public boolean explain;
        /// Travel minutes between two locations, both ways; see [#travel].
        public final Map<String, Integer> travel = new HashMap<String, Integer>();

        public void travel(String from, String to, int minutes) {
            travel.put(from + "|" + to, Integer.valueOf(minutes));
        }
    }

    /// A start that can be booked, and who it would be with.
    public static final class Slot {
        public final long start;
        public final long end;
        public final List<String> providers;
        public final String resourceId;
        public final int seatsLeft;

        Slot(long start, long end, List<String> providers, String resourceId, int seatsLeft) {
            this.start = start;
            this.end = end;
            this.providers = providers;
            this.resourceId = resourceId;
            this.seatsLeft = seatsLeft;
        }
    }

    /// One start for one provider, and the rule that decided it. A verdict
    /// with an empty provider is about the start itself (too soon, the
    /// client's own day).
    public static final class Verdict {
        public final long start;
        public final String provider;
        public final String reason;

        Verdict(long start, String provider, String reason) {
            this.start = start;
            this.provider = provider;
            this.reason = reason;
        }
    }

    public static final class Result {
        public final List<Slot> slots = new ArrayList<Slot>();
        public final List<Verdict> verdicts = new ArrayList<Verdict>();

        /// The slot at `start`, or null when it cannot be booked.
        public Slot at(long start) {
            for (int iter = 0; iter < slots.size(); iter++) {
                if (slots.get(iter).start == start) {
                    return slots.get(iter);
                }
            }
            return null;
        }

        /// The first reason given for `start`, preferring one about the start
        /// itself; [#OK] when it can be booked, empty when it was not tried.
        public String reason(long start) {
            if (at(start) != null) {
                return OK;
            }
            String any = "";
            for (int iter = 0; iter < verdicts.size(); iter++) {
                Verdict v = verdicts.get(iter);
                if (v.start == start) {
                    if (v.provider.length() == 0) {
                        return v.reason;
                    }
                    if (any.length() == 0 && !OK.equals(v.reason)) {
                        any = v.reason;
                    }
                }
            }
            return any;
        }
    }

    public static Result evaluate(Request r) {
        Result out = new Result();
        Need need = r.need;
        Rules rules = r.rules;
        long length = need.minutes * MINUTE;
        long before = need.bufferBefore * MINUTE;
        long after = need.bufferAfter * MINUTE;
        long earliest = rules.now + rules.minNoticeMinutes * MINUTE;
        long latest = rules.horizonDays <= 0 ? Long.MAX_VALUE
                : rules.now + rules.horizonDays * 86400000L;
        boolean group = need.capacity > 1;
        int needed = Math.max(1, need.providersNeeded);
        for (int iter = 0; iter < r.starts.length; iter++) {
            long start = r.starts[iter];
            long end = start + length;
            String startReason = startRule(r, start, end, earliest, latest);
            if (startReason != null) {
                // Recorded even without explain: the client is told why a day
                // has nothing, and the reason for its first start is that.
                out.verdicts.add(new Verdict(start, "", startReason));
                continue;
            }
            List<Party> free = new ArrayList<Party>();
            boolean requiredMissing = false;
            Party joining = null;
            int groupSeatsLeft = need.capacity;
            for (int p = 0; p < r.providers.size(); p++) {
                Party party = r.providers.get(p);
                String groupKey = group ? groupKey(need.serviceId, party.id, start) : "";
                Integer taken = group ? r.groupSeats.get(groupKey) : null;
                String reason;
                if (taken != null) {
                    // A group slot that is already running with this provider
                    // is joined, not checked again: its time was decided when
                    // its first seat was booked.
                    reason = taken.intValue() >= need.capacity ? GROUP_FULL : OK;
                } else {
                    reason = providerRule(r, party, start - before, end + after, groupKey);
                }
                if (r.explain) {
                    out.verdicts.add(new Verdict(start, party.id, reason));
                }
                if (OK.equals(reason)) {
                    free.add(party);
                    if (taken != null && joining == null) {
                        joining = party;
                        groupSeatsLeft = need.capacity - taken.intValue();
                    }
                } else if (r.required.contains(party.id)) {
                    requiredMissing = true;
                }
            }
            if (requiredMissing) {
                continue;
            }
            if (joining != null && (r.required.size() == 0 || r.required.contains(joining.id))) {
                // A seat in a group slot that is already running goes with it:
                // its provider and its resource were settled by the first seat.
                List<String> with = new ArrayList<String>();
                with.add(joining.id);
                out.slots.add(new Slot(start, end, with, "", groupSeatsLeft));
                continue;
            }
            List<String> chosen = choose(r, free, needed);
            if (chosen == null) {
                if (!r.explain && r.providers.size() > 0) {
                    out.verdicts.add(new Verdict(start, "", free.size() == 0
                            ? firstProviderReason(r, start - before, end + after, start)
                            : NOT_ENOUGH_PROVIDERS));
                }
                continue;
            }
            String resource = "";
            if (need.resourceKind.length() > 0) {
                resource = freeResource(r, start - before, end + after);
                if (resource == null) {
                    out.verdicts.add(new Verdict(start, "", NO_RESOURCE));
                    continue;
                }
            }
            out.slots.add(new Slot(start, end, chosen, resource, group ? groupSeatsLeft : 1));
        }
        return out;
    }

Starts are read off the business’s local clock, so a day when the clocks go forward has no slot at a time that doesn’t exist:

/// The start times of a day, read off the local clock every `step`
/// minutes. A time that does not exist that day -- skipped by a change to
/// summer time -- is left out rather than moved.
public static long[] starts(LocalDate day, ZoneId zone, int step) {
    int each = Math.max(SlotEngine.CELL_MINUTES, step);
    List<Long> out = new ArrayList<Long>();
    for (int minute = 0; minute < 24 * 60; minute += each) {
        LocalTime wanted = LocalTime.of(minute / 60, minute % 60);
        ZonedDateTime at = ZonedDateTime.of(day, wanted, zone);
        if (!at.toLocalTime().equals(wanted) || !at.toLocalDate().equals(day)) {
            continue;
        }
        out.add(Long.valueOf(at.toInstant().toEpochMilli()));
    }
    long[] starts = new long[out.size()];
    for (int iter = 0; iter < starts.length; iter++) {
        starts[iter] = out.get(iter).longValue();
    }
    return starts;
}

Never double booked

Time is claimed in five-minute cells. A SlotClaim row’s key is the party and the cell, so two appointments over the same five minutes of one stylist or one chair are refused by the database itself, however many clients tap at once. A client picking a start first holds it, in a transaction of its own, for the business’s hold time:

/// Holds a start for `client` while they finish booking. Answers 409 with
/// the reason when the start is not free, or was taken a moment ago.
public AppointmentDto hold(String client, String clientName, HoldRequestDto ask)
        throws IOException {
    if (ask == null) {
        throw new ResponseStatusException(400, "Nothing to hold");
    }
    AppointmentDto held;
    try {
        held = placeHold(client, clientName, ask);
    } catch (ResponseStatusException answered) {
        throw answered;
    } catch (IOException failed) {
        throw taken(client, ask.serviceId(), ask.locationId(), ask.start(), failed);
    } catch (RuntimeException failed) {
        throw taken(client, ask.serviceId(), ask.locationId(), ask.start(), failed);
    }
    changed(held, null);
    return held;
}

Placing the booking asks the engine again for that one start, then writes the parties and claims their cells:

/// Puts `a` at `start`: asks the engine, writes who and what it takes, and
/// claims their cells. A group service joins the slot's existing seat
/// count, or opens it.
private void place(Appointment a, Business business, Service service, Location location,
        long start, List<String> required, long now) throws IOException {
    List<Provider> candidates = availability.candidates(service, required);
    for (int iter = 0; iter < candidates.size(); iter++) {
        providers.hold(candidates.get(iter).getUsername());
    }
    ZoneId zone = ZoneId.of(location.getTimeZone());
    java.time.LocalDate day = Instant.ofEpochMilli(start).atZone(zone).toLocalDate();
    SlotEngine.Request request = availability.request(business, service, location, day,
            candidates, required, a.getClient(), now, a.getId());
    if (!offered(request.starts, start)) {
        throw new ResponseStatusException(400, "That is not a start this day offers");
    }
    request.starts = new long[] {start};
    request.explain = true;
    SlotEngine.Result result = SlotEngine.evaluate(request);
    SlotEngine.Slot slot = result.at(start);
    if (slot == null) {
        throw new ResponseStatusException(409, say(result.reason(start)));
    }
    a.setStartsAt(Instant.ofEpochMilli(start));
    a.setEndsAt(Instant.ofEpochMilli(slot.end));
    a.setBlockStart(Instant.ofEpochMilli(start - service.getBufferBefore() * 60000L));
    a.setBlockEnd(Instant.ofEpochMilli(slot.end + service.getBufferAfter() * 60000L));
    List<String> refs = new ArrayList<String>();
    for (int iter = 0; iter < slot.providers.size(); iter++) {
        String username = slot.providers.get(iter);
        refs.add(username);
        party(a.getId(), States.PROVIDER, username, displayName(candidates, username));
    }
    if (slot.resourceId.length() > 0) {
        refs.add(slot.resourceId);
        party(a.getId(), States.RESOURCE, slot.resourceId, slot.resourceId);
    }
    if (service.getCapacity() > 1) {
        String key = SlotEngine.groupKey(service.getId(), slot.providers.get(0), start);
        a.setGroupKey(key);
        if (request.groupSeats.containsKey(key)) {
            if (groups.take(key) == 0) {
                throw new ResponseStatusException(409, say(SlotEngine.GROUP_FULL));
            }
            return;
        }
        GroupSlot group = groups.findById(key);
        if (group != null) {
            // Every seat of an earlier slot was given back; it is opened again.
            if (groups.take(key) == 0) {
                throw new ResponseStatusException(409, say(SlotEngine.GROUP_FULL));
            }
        } else {
            group = new GroupSlot();
            group.setId(key);
            group.setAppointmentId(a.getId());
            group.setSeats(1);
            group.setCapacity(service.getCapacity());
            groups.insert(group);
        }
        claim(key, refs, a.getBlockStart().toEpochMilli(), a.getBlockEnd().toEpochMilli());
        return;
    }
    a.setGroupKey("");
    claim(a.getId(), refs, a.getBlockStart().toEpochMilli(), a.getBlockEnd().toEpochMilli());
}

/// One row per party and five-minute cell. The key is the party and the
/// cell, so an overlapping booking cannot be written.
private void claim(String owner, List<String> refs, long from, long to) {
    for (int r = 0; r < refs.size(); r++) {
        // Cell numbers, not millis: the last cell is the one `to - 1` is in.
        long last = SlotEngine.cell(to - 1);
        for (long cell = SlotEngine.cell(from); cell <= last; cell++) {
            SlotClaim c = new SlotClaim();
            c.setId(refs.get(r) + "|" + cell);
            c.setAppointmentId(owner);
            c.setRef(refs.get(r));
            c.setCell(cell);
            claims.insert(c);
        }
    }
}

A refused start says why, in words a client understands:

/// What a client is told when a start is turned down.
public static String say(String reason) {
    if (SlotEngine.TOO_SOON.equals(reason)) {
        return "That is too soon to book";
    }
    if (SlotEngine.TOO_FAR.equals(reason)) {
        return "That is further ahead than bookings open";
    }
    if (SlotEngine.OUTSIDE_HOURS.equals(reason)) {
        return "Nobody is working then";
    }
    if (SlotEngine.BOOKED.equals(reason) || SlotEngine.TIME_OFF.equals(reason)
            || SlotEngine.CALENDAR.equals(reason)) {
        return "That time is taken";
    }
    if (SlotEngine.TRAVEL.equals(reason)) {
        return "There is not enough time to travel between locations";
    }
    if (SlotEngine.DAY_FULL.equals(reason)) {
        return "That day is fully booked";
    }
    if (SlotEngine.NEEDS_BREAK.equals(reason)) {
        return "That time is kept for a break";
    }
    if (SlotEngine.LEAVES_GAP.equals(reason) || SlotEngine.NOT_ADJACENT.equals(reason)) {
        return "That time would leave a gap too short to book. Try a time next to"
                + " another booking";
    }
    if (SlotEngine.NO_RESOURCE.equals(reason)) {
        return "Every room is in use then";
    }
    if (SlotEngine.CLIENT_BUSY.equals(reason)) {
        return "You already have a booking then";
    }
    if (SlotEngine.CLIENT_GAP.equals(reason)) {
        return "That is too close to another of your bookings";
    }
    if (SlotEngine.CLIENT_DAY_FULL.equals(reason)) {
        return "You already have as many bookings that day as you can";
    }
    if (SlotEngine.GROUP_FULL.equals(reason)) {
        return "Every place is taken";
    }
    return "Nobody who offers that is free then";
}

The app’s slot picker shows the server’s starts and adds the one thing the server can’t know: with the calendar check on, a start that clashes with the client’s own phone calendar is marked, though it can still be booked:

/// Choosing when: a service, who with, and a free start, by the day or the
/// week.
///
/// The free starts are the server's answer and nobody else's: the slot
/// engine has every provider's hours, bookings, buffers and connected
/// calendars, and the rules the business set. The phone adds one thing the
/// server cannot know -- the client's own calendar on this phone. With
/// "Check my calendar" on, a start that clashes with something there is
/// marked, though it can still be booked: the client knows whether that
/// dentist appointment can move.
///
/// The same screen moves an existing appointment: given one, a start is a
/// reschedule rather than a new hold.
public final class BookForm extends LiveChannel.Adapter {
    private static final String CHECK_CALENDAR = "slate.checkCalendar";
    private static final int DAYS_SHOWN = 21;
    private static final String DAY = "day";
    private static final String WEEK = "week";

    private final Form form;
    private final AppointmentDto moving;
    private final Container choices = new Container(BoxLayout.y());
    private final Container slots = new Container(BoxLayout.y());
    // Below the times, not above them: the times are what the screen is for,
    // and the switch is set once and left alone.
    private final Container myCalendar = new Container(BoxLayout.y());
    private final List<ServiceDto> services = new ArrayList<ServiceDto>();
    private final List<ProviderDto> providers = new ArrayList<ProviderDto>();
    private ServiceDto service;
    private String location = "";
    private String provider = "";
    private String view = DAY;
    private long day = Times.dayStart(System.currentTimeMillis());
    /// The client's own busy time on this phone over the days shown, once read.
    private List<long[]> mine = new ArrayList<long[]>();

    private BookForm(Form previous, ServiceDto service, AppointmentDto moving) {
        this.service = service;
        this.moving = moving;
        form = Ui.form(moving == null ? "Book" : "Move appointment", "Book");
        Ui.back(form, previous);
        Container page = Ui.page();
        choices.setName("choices");
        slots.setName("slots");
        page.add(choices).add(slots).add(myCalendar);
        Layouts.broaden(page);
        form.add(BorderLayout.CENTER, page);
        form.addShowListener(e -> LiveChannel.listen(this));
    }

    /// Books `service`, or asks which first when it is null.
    public static void show(Form previous, ServiceDto service) {
        new BookForm(previous, service, null).load();
    }

    /// Picks a new time for `a`, with the same service and provider.
    public static void reschedule(Form previous, AppointmentDto a) {
        BookForm book = new BookForm(previous, null, a);
        book.provider = Appointments.providerRef(a);
        book.day = Times.dayStart(a.start());
        book.load();
    }

    private void load() {
        form.show();
        Api.business().services(Net.to(list -> {
            services.clear();
            services.addAll(list);
            String wanted = moving != null ? moving.serviceId() : service == null ? "" : service.id();
            service = null;
            for (int iter = 0; iter < list.size(); iter++) {
                if (list.get(iter).id().equals(wanted)) {
                    service = list.get(iter);
                }
            }
            if (service == null && !list.isEmpty()) {
                service = list.get(0);
            }
            Api.business().providers(Net.to(people -> {
                providers.clear();
                providers.addAll(people);
                Api.business().locations(Net.to(this::located, Ui::fail));
            }, Ui::fail));
        }, Ui::fail));
    }

    private void located(List<LocationDto> places) {
        location = moving != null ? moving.locationId() : places.isEmpty() ? "" : places.get(0).id();
        buildChoices(places);
        readMine();
    }

    private void buildChoices(final List<LocationDto> places) {
        choices.removeAll();
        if (service == null) {
            choices.add(Ui.empty(FontImage.MATERIAL_EVENT_BUSY, "Nothing to book",
                    "This business has no services on offer yet."));
            Ui.relayout(choices);
            return;
        }
        if (moving == null && services.size() > 1) {
            String[] labels = new String[services.size()];
            String[] values = new String[services.size()];
            for (int iter = 0; iter < services.size(); iter++) {
                labels[iter] = services.get(iter).name();
                values[iter] = services.get(iter).id();
            }
            choices.add(Ui.label(ClientForm.business().serviceNoun(), "SvLabel"));
            choices.add(Ui.pills("service", labels, values, service.id(), id -> {
                for (int iter = 0; iter < services.size(); iter++) {
                    if (services.get(iter).id().equals(id)) {
                        service = services.get(iter);
                    }
                }
                provider = "";
                buildChoices(places);
                loadSlots();
            }));
        }
        choices.add(Ui.text(service.description() == null ? "" : service.description(), "SvText"));
        // What it takes and costs, before a time is picked: every booking app
        // answers this first.
        String terms = Times.length(service.minutes()) + Ui.DOT
                + Ui.money(service.priceCents(), service.currency());
        if (service.depositPercent() > 0) {
            terms += Ui.DOT + Lang.tr("Deposit {0}", Ui.money(service.priceCents()
                    * service.depositPercent() / 100, service.currency()));
        }
        choices.add(Ui.text(terms, "SvCaption"));
        if (places.size() > 1 && moving == null && !service.online()) {
            String[] labels = new String[places.size()];
            String[] values = new String[places.size()];
            for (int iter = 0; iter < places.size(); iter++) {
                labels[iter] = places.get(iter).name();
                values[iter] = places.get(iter).id();
            }
            choices.add(Ui.label("Where", "SvLabel"));
            choices.add(Ui.pills("location", labels, values, location, id -> {
                location = id;
                loadSlots();
            }));
        }
        List<ProviderDto> offering = offering();
        String[] labels = new String[offering.size() + 1];
        String[] values = new String[offering.size() + 1];
        labels[0] = Lang.tr("Anyone");
        values[0] = "";
        for (int iter = 0; iter < offering.size(); iter++) {
            labels[iter + 1] = offering.get(iter).displayName();
            values[iter + 1] = offering.get(iter).username();
        }
        choices.add(Ui.label(ClientForm.business().providerNoun(), "SvLabel"));
        choices.add(Ui.pills("provider", labels, values, provider, who -> {
            provider = who;
            loadSlots();
        }));

        Container when = new Container(new BorderLayout());
        when.add(BorderLayout.CENTER, Ui.choice("view", new String[] {Lang.tr("Day"), Lang.tr("Week")},
                new String[] {DAY, WEEK}, view, chosen -> {
                    view = chosen;
                    loadSlots();
                }));
        choices.add(when);
        myCalendar.removeAll();
        if (DeviceCalendar.canReadBusy()) {
            myCalendar.add(Ui.row(FontImage.MATERIAL_EVENT_NOTE, "Check my calendar",
                    "Marks times that clash with this phone's calendar",
                    Ui.toggle("checkCalendar", Preferences.get(CHECK_CALENDAR, false), on -> {
                        Preferences.set(CHECK_CALENDAR, on);
                        readMine();
                    })));
        }
        choices.add(dayStrip());
        Ui.relayout(choices);
    }

    private Container dayStrip() {
        long today = Times.dayStart(System.currentTimeMillis());
        String[] labels = new String[DAYS_SHOWN];
        String[] values = new String[DAYS_SHOWN];
        for (int iter = 0; iter < DAYS_SHOWN; iter++) {
            long d = Times.addDays(today, iter);
            labels[iter] = iter == 0 ? Lang.tr("Today") : iter == 1 ? Lang.tr("Tomorrow") : Times.shortDay(d);
            values[iter] = Times.iso(d);
        }
        return Ui.pills("day", labels, values, Times.iso(day), iso -> {
            for (int iter = 0; iter < DAYS_SHOWN; iter++) {
                long d = Times.addDays(today, iter);
                if (Times.iso(d).equals(iso)) {
                    day = d;
                }
            }
            loadSlots();
        });
    }

    private List<ProviderDto> offering() {
        List<ProviderDto> out = new ArrayList<ProviderDto>();
        for (int iter = 0; iter < providers.size(); iter++) {
            ProviderDto p = providers.get(iter);
            if (p.services() != null && p.services().contains(service.id())) {
                out.add(p);
            }
        }
        return out;
    }

    /// The client's own busy time, when they asked for it to be checked.
    private void readMine() {
        if (!DeviceCalendar.canReadBusy() || !Preferences.get(CHECK_CALENDAR, false)) {
            mine = new ArrayList<long[]>();
            loadSlots();
            return;
        }
        long from = Times.dayStart(System.currentTimeMillis());
        DeviceCalendar.busy(null, from, Times.addDays(from, DAYS_SHOWN + 7), busy -> {
            mine = busy;
            loadSlots();
        });
    }

    @Override
    public void slotsChanged(String changedService, String changedDay) {
        if (service == null || changedService.length() > 0 && !changedService.equals(service.id())) {
            return;
        }
        if (changedDay.length() > 0 && DAY.equals(view) && !changedDay.equals(Times.iso(day))) {
            return;
        }
        loadSlots();
    }

    private void loadSlots() {
        if (service == null) {
            return;
        }
        slots.removeAll();
        slots.add(new com.codename1.appkit.Skeleton(3, "SvSkeleton"));
        Ui.relayout(slots);
        if (WEEK.equals(view)) {
            List<String> who = provider.length() == 0 ? new ArrayList<String>()
                    : Collections.singletonList(provider);
            Api.business().findTime(new FindTimeRequestDto(service.id(), location, Times.iso(day), 7,
                    who), Net.to(this::showWeek, Ui.failed(slots, this::loadSlots)));
        } else {
            Api.business().slots(service.id(), location, Times.iso(day), provider,
                    Net.to(this::showDay, Ui.failed(slots, this::loadSlots)));
        }
    }

When a booking is given back, the first waiting client it fits is told:

/// A booking of `serviceId` with `providers` at `start` was given back.
/// The first wish it fits is offered it.
public void freed(String serviceId, List<String> providers, long start) throws IOException {
    Waitlist offered = offer(serviceId, providers, start);
    if (offered != null) {
        notifier.tell(offered.getClient(), Notifier.OFFER, "",
                "A time you wanted is free",
                "Book it before someone else does");
    }
}

@Transactional(rollbackFor = IOException.class)
private Waitlist offer(String serviceId, List<String> providers, long start) {
    if (start <= System.currentTimeMillis()) {
        return null;
    }
    ZonedDateTime at = Instant.ofEpochMilli(start).atZone(catalog.zone());
    long day = at.toLocalDate().toEpochDay();
    int minute = at.getHour() * 60 + at.getMinute();
    List<Waitlist> waiting = waitlist.findByServiceIdAndStateOrderByCreatedAtAsc(serviceId,
            States.WAITING);
    for (int iter = 0; iter < waiting.size(); iter++) {
        Waitlist w = waiting.get(iter);
        boolean fits = day >= w.getFromDay() && day <= w.getToDay()
                && minute >= w.getFromMinute() && minute < w.getToMinute()
                && (w.getProvider().length() == 0 || providers.contains(w.getProvider()));
        if (fits && waitlist.move(w.getId(), States.WAITING, States.OFFERED,
                Instant.now()) == 1) {
            w.setState(States.OFFERED);
            w.setOffered(Long.toString(start));
            waitlist.save(w);
            return w;
        }
    }
    return null;
}

Rules an owner can try first

Changing the gap rules changes what every client is offered, so the policy screen previews a day under draft rules before saving them. The business row is copied, the draft applied to the copy, and the engine explains every start for every provider:

/// What a day would offer under rules that are not saved: the business
/// row is copied, the draft rules applied to the copy, and the engine asked
/// to explain every start for every provider. Nothing is written.
@Transactional(readOnly = true)
public PreviewDto preview(PreviewRequestDto ask) {
    if (ask == null) {
        throw new ResponseStatusException(400, "Nothing to preview");
    }
    Business draft = copy(catalog.main());
    if (ask.policy() != null) {
        Catalog.apply(ask.policy(), draft);
    }
    Service service = catalog.offered(ask.serviceId());
    Location location = catalog.openLocation(ask.locationId());
    LocalDate day = day(ask.day());
    List<Provider> candidates = candidates(service, new ArrayList<String>());
    // The preview is of the rules, not of the clock: a day that is already
    // half over would show most of it as too soon.
    long now = ZonedDateTime.of(day, LocalTime.MIDNIGHT, ZoneId.of(location.getTimeZone()))
            .toInstant().toEpochMilli() - (long) draft.getMinNoticeMinutes() * 60000L;
    SlotEngine.Request request = request(draft, service, location, day, candidates,
            new ArrayList<String>(), "", now, null);
    request.explain = true;
    SlotEngine.Result result = SlotEngine.evaluate(request);
/// The booking rules, with what they do shown before they are saved.
///
/// Rules interact: a ten-minute step with a thirty-minute minimum gap and
/// tight packing leaves far fewer starts than any of them alone suggests.
/// "Preview" runs the slot engine with the edited policy against tomorrow's
/// real schedule (`/api/owner/preview`), without saving anything, and shows
/// every provider's starts with the reason each one is refused.
public final class PolicyForm {
    private final Form form = Ui.form("Booking rules", "Policy");
    private final Container page = Ui.page();
    private final Container preview = new Container(BoxLayout.y());
    private TextField step;
    private TextField notice;
    private TextField horizon;
    private TextField hold;
    private TextField cancel;
    private TextField reschedule;
    private TextField deposit;
    private TextField noShow;
    private TextField clientGap;
    private TextField clientPerDay;
    private TextField usableGap;
    private TextField reminder;
    private String packing = "ANY";
    private PolicyDto loaded;
    private String serviceId = "";
    private String locationId = "";

    private PolicyForm(Form previous) {
        Ui.back(form, previous);
        form.add(BorderLayout.CENTER, page);
        preview.setName("preview");
        Api.business().policy(Net.to(this::fill, Ui::fail));
        Api.owner().services(Net.to(this::services, Ui::fail));
        Api.business().locations(Net.to(found -> {
            if (!found.isEmpty()) {
                locationId = found.get(0).id();
            }
        }, Ui::fail));
    }

    public static void show(Form previous) {
        new PolicyForm(previous).form.show();
    }

    private void services(List<ServiceDto> services) {
        if (!services.isEmpty()) {
            serviceId = services.get(0).id();
        }
    }

    private void fill(PolicyDto p) {
        loaded = p;
        packing = p.packing() == null ? "ANY" : p.packing();
        page.removeAll();
        Ui.section(page, "Starts");
        step = CatalogForm.number(page, "Start times every, minutes", "step", p.stepMinutes());
        notice = CatalogForm.number(page, "Book at least, minutes ahead", "notice",
                p.minNoticeMinutes());
        horizon = CatalogForm.number(page, "Book at most, days ahead", "horizon", p.horizonDays());
        page.add(Ui.label("Packing", "SvLabel"));
        page.add(Ui.choice("packing", new String[] {"Any free start", "Next to other bookings"},
                new String[] {"ANY", "TIGHT"}, packing, chosen -> packing = chosen));
        usableGap = CatalogForm.number(page, "Never leave a gap shorter than, minutes",
                "usable-gap", p.minUsableGapMinutes());
        Ui.section(page, "Clients");
        clientGap = CatalogForm.number(page, "Between one client's appointments, minutes",
                "client-gap", p.clientGapMinutes());
        clientPerDay = CatalogForm.number(page, "Appointments a client may have a day",
                "client-per-day", p.clientPerDay());
        hold = CatalogForm.number(page, "A chosen time is held for, minutes", "hold",
                p.holdMinutes());
        Ui.section(page, "Money and changes");
        deposit = CatalogForm.number(page, "Deposit, percent", "deposit", p.depositPercent());
        cancel = CatalogForm.number(page, "Free to cancel until, hours before", "cancel",
                p.cancelHours());
        reschedule = CatalogForm.number(page, "Can be moved until, hours before", "reschedule",
                p.rescheduleHours());
        noShow = CatalogForm.number(page, "No-show fee, percent", "no-show", p.noShowFeePercent());
        reminder = CatalogForm.number(page, "Remind clients, hours before", "reminder",
                p.reminderHours());
        page.add(Ui.secondary("Preview tomorrow", "run-preview", e -> preview()));
        page.add(preview);
        page.add(Ui.primary("Save rules", "save", e -> Api.owner().savePolicy(edited(),
                Net.to(saved -> {
                    Ui.say("Rules saved");
                    form.getBackCommand().actionPerformed(null);
                }, Ui::fail))));
        Layouts.broaden(page);
        Ui.relayout(page);
    }

    private PolicyDto edited() {
        return new PolicyDto(CatalogForm.num(step), CatalogForm.num(notice),
                CatalogForm.num(horizon), CatalogForm.num(hold), CatalogForm.num(cancel),
                CatalogForm.num(reschedule), CatalogForm.num(deposit), CatalogForm.num(noShow),
                CatalogForm.num(clientGap), CatalogForm.num(clientPerDay),
                CatalogForm.num(usableGap), packing, CatalogForm.num(reminder));
    }

    private void preview() {
        if (loaded == null || serviceId.length() == 0 || locationId.length() == 0) {
            return;
        }
        String day = Times.iso(Times.addDays(System.currentTimeMillis(), 1));
        Api.owner().preview(new PreviewRequestDto(serviceId, locationId, day, edited()),
                Net.to(this::showPreview, Ui::fail));
    }

    private void showPreview(PreviewDto p) {
        preview.removeAll();
        preview.add(Ui.label(Lang.tr("{0} bookable starts", String.valueOf(p.bookable())),
                "SvHeading"));
        for (int iter = 0; iter < p.rows().size(); iter++) {
            PreviewRowDto row = p.rows().get(iter);
            preview.add(Ui.label(row.name(), "SvCaption"));
            int columns = Math.max(1, Math.min(6, Layouts.columns(page.getWidth(), 14f, 8)));
            Container grid = new Container(new GridLayout(
                    (row.reasons().size() + columns - 1) / columns, columns));
            grid.setName("preview-" + row.provider());
            for (int i = 0; i < row.reasons().size(); i++) {
                String reason = row.reasons().get(i);
                String clock = Times.minuteOfDay(p.starts().get(i).intValue());
                boolean ok = "OK".equals(reason);
                grid.add(Ui.text(ok ? clock : clock + "\n" + Reasons.say(reason),
                        ok ? "SvSlot" : "SvSlotRefused"));
            }
            preview.add(grid);
        }
        Ui.relayout(page);
    }
}

Saved rules are validated on the server:

/// Checks a set of rules and copies them onto `row`. Answers 400 naming
/// the first value that cannot be a rule. Shared by saving the rules and
/// previewing them, so a preview never shows rules that could not be saved.
public static void apply(PolicyDto p, Business row) {
    if (p == null) {
        throw new ResponseStatusException(400, "No rules to apply");
    }
    int step = grid(p.stepMinutes(), 5, 240, "the time between starts");
    row.setStepMinutes(step);
    row.setMinNoticeMinutes(range(p.minNoticeMinutes(), 0, 60 * 24 * 30, "the notice needed"));
    row.setHorizonDays(range(p.horizonDays(), 1, 366, "how far ahead people can book"));
    row.setHoldMinutes(range(p.holdMinutes(), 1, 60, "how long a slot is held"));
    row.setCancelHours(range(p.cancelHours(), 0, 24 * 14, "the cancellation window"));
    row.setRescheduleHours(range(p.rescheduleHours(), 0, 24 * 14, "the rescheduling window"));
    row.setDepositPercent(range(p.depositPercent(), 0, 100, "the deposit"));
    row.setNoShowFeePercent(range(p.noShowFeePercent(), 0, 100, "the no-show fee"));
    row.setClientGapMinutes(grid(p.clientGapMinutes(), 0, 60 * 24, "the gap between a client's bookings"));
    row.setClientPerDay(range(p.clientPerDay(), 0, 20, "the bookings a client may make a day"));
    row.setMinUsableGapMinutes(grid(p.minUsableGapMinutes(), 0, 240, "the smallest useful gap"));
    String packing = p.packing() == null ? ANY : p.packing();
    if (!ANY.equals(packing) && !TIGHT.equals(packing)) {
        throw new ResponseStatusException(400, "Packing is either ANY or TIGHT");
    }
    row.setPacking(packing);
    row.setReminderHours(range(p.reminderHours(), 0, 24 * 7, "when reminders go out"));
}

Hours and schedules

A provider’s week is shifts on the five-minute grid. Saving new hours never moves a booking already made: hours decide what’s offered, not what was sold.

/// Replaces a provider's week. Shifts are on the five-minute grid, at an
/// open location, and do not overlap on one day. Bookings already made
/// keep their time: hours decide what is offered, not what was sold.
public WeekDto saveWeek(String username, WeekDto week) throws IOException {
    WeekDto saved = saveWeekTx(username, week);
    live.scheduleChanged(username);
    return saved;
}
private void save() {
    List<WorkingHoursDto> hours = new ArrayList<WorkingHoursDto>();
    for (int d = 0; d < 7; d++) {
        if (!open[d].isSelected()) {
            continue;
        }
        int start = starts[d].getTime();
        int end = ends[d].getTime();
        if (end <= start) {
            Ui.say(Lang.tr("{0} ends before it starts", Lang.tr(DAYS[d])));
            return;
        }
        hours.add(new WorkingHoursDto(null, location, d + 1, start, end));
    }
    Api.provider().saveHours(new WeekDto(hours), Net.to(saved -> {
        Ui.say("Hours saved");
        form.getBackCommand().actionPerformed(null);
    }, Ui::fail));
}

The provider’s day, week and agenda are one schedule read over a range, drawn as a timeline: a lane per day, the working hours as a band, bookings as blocks as long as they last, calendar busy time shaded and a line at the current time. The owner’s day board is the same component with a lane per provider, so a gap reads the same on both:

/// One provider's schedule for `days` days from `from`, the start of a day
/// on the device that asks.
///
/// The device's day is not the location's: midnight in Paris is 23:00 the
/// day before in London, and reading `from` as a London date answered a
/// Paris phone's "today" with yesterday. So the answer covers every day of
/// the location that the device's range touches, and the device keeps what
/// falls inside its own days.
@Transactional(readOnly = true)
public ScheduleDto schedule(String username, long from, int days) {
    Provider p = provider(username);
    int span = Math.max(1, Math.min(MAX_DAYS, days));
    ZoneId zone = zoneOf(p.getDefaultLocation());
    LocalDate first = Instant.ofEpochMilli(from).atZone(zone).toLocalDate();
    LocalDate last = Instant.ofEpochMilli(from + span * 86400000L - 1).atZone(zone)
            .toLocalDate();
    int touched = (int) (last.toEpochDay() - first.toEpochDay()) + 1;
    return schedule(p, first, Math.min(MAX_DAYS + 1, touched), zone);
}
/// The provider's home: their own time, by the day, the week or as an
/// agenda, with every appointment, every block a connected calendar says is
/// busy and the hours they work, merged into one timeline.
///
/// One request answers all of it (`/api/provider/schedule`), and the live
/// channel's `schedule` frame reloads it when a client books, moves or
/// cancels, so nothing here polls.
public class ProviderForm extends LiveChannel.Adapter implements Nav.Home {
    private static final String DAY = "day";
    private static final String WEEK = "week";
    private static final String AGENDA = "agenda";

    private final Form form = Ui.home("Provider");
    private final Menu menu = new Menu(form, Nav.PROVIDER);
    private final Container timeline = Ui.list("timeline");
    private final Container heading = new Container(new BorderLayout());
    private final Label title = Ui.label("", "SvHeading");
    private String view = DAY;
    private long from = Times.dayStart(System.currentTimeMillis());

    @Override
    public Form form() {
        return form;
    }

    @Override
    public void start() {
        menu.item(FontImage.MATERIAL_TODAY, "Schedule", "schedule", () -> form.showBack());
        menu.item(FontImage.MATERIAL_SCHEDULE, "Working hours", "hours", () -> HoursForm.show(form));
        menu.item(FontImage.MATERIAL_BEACH_ACCESS, "Time off", "time-off",
                () -> TimeOffForm.show(form));
        menu.item(FontImage.MATERIAL_SYNC, "Calendars", "calendars", () -> CalendarsForm.show(form));

        Container bar = new Container(new BorderLayout());
        bar.setUIID("SvHomeBar");
        bar.add(BorderLayout.WEST, menu.flatButton());
        UserDto user = Session.user();
        bar.add(BorderLayout.CENTER, Ui.label(user == null ? "" : user.displayName(), "SvTitle"));
        bar.add(BorderLayout.EAST, Ui.flat(FontImage.MATERIAL_SETTINGS, "Settings", "home-settings",
                e -> SettingsForm.show(form)));

        Container top = new Container(BoxLayout.y());
        top.add(bar);
        top.add(Ui.choice("view", new String[] {"Day", "Week", "Agenda"},
                new String[] {DAY, WEEK, AGENDA}, view, chosen -> {
                    view = chosen;
                    from = WEEK.equals(view) ? Times.weekStart(from) : from;
                    load();
                }));
        heading.add(BorderLayout.WEST, Ui.flat(FontImage.MATERIAL_CHEVRON_LEFT, "Earlier",
                "earlier", e -> move(-1)));
        heading.add(BorderLayout.EAST, Ui.flat(FontImage.MATERIAL_CHEVRON_RIGHT, "Later",
                "later", e -> move(1)));
        title.setName("range");
        heading.add(BorderLayout.CENTER, title);
        top.add(heading);

        Container shell = new Container(new BorderLayout());
        shell.add(BorderLayout.NORTH, top);
        shell.add(BorderLayout.CENTER, timeline);
        Ui.pull(timeline, this::load);
        form.add(shell);
        menu.install();
        form.addShowListener(e -> {
            LiveChannel.listen(this);
            DeviceSync.resume();
            load();
        });
    }

    @Override
    public void scheduleChanged(String username) {
        load();
    }

    @Override
    public void appointmentChanged(String id, String state) {
        load();
    }

    private int days() {
        return DAY.equals(view) ? 1 : WEEK.equals(view) ? 7 : 14;
    }

    private void move(int direction) {
        from = Times.addDays(from, direction * days());
        load();
    }

    private void load() {
        title.setText(days() == 1 ? Ui.weekday(from)
                : Times.monthDay(from) + " - " + Times.monthDay(Times.addDays(from, days() - 1)));
        Api.provider().schedule(from, days(), Net.to(this::show, Ui.failed(timeline, this::load)));
    }

    private void show(ScheduleDto s) {
        if (!Nav.isHome(form)) {
            return;
        }
        timeline.removeAll();
        boolean agenda = AGENDA.equals(view);
        if (!agenda) {
            timeline.add(grid(s));
            timeline.add(Timeline.legend());
            Layouts.broaden(timeline);
            Ui.relayout(timeline);
            return;
        }
        int shown = 0;
        for (int d = 0; d < days(); d++) {
            long dayFrom = Times.addDays(from, d);
            long dayTo = Times.addDays(from, d + 1);
            List<Object> items = items(s, dayFrom, dayTo, agenda);
            if (items.isEmpty() && agenda) {
                continue;
            }
            if (days() > 1) {
                timeline.add(Ui.label(Ui.weekday(dayFrom), "SvSection"));
            }
            if (items.isEmpty()) {
                timeline.add(Ui.text(Lang.tr("Not working"), "SvSmall"));
            }
            for (int iter = 0; iter < items.size(); iter++) {
                timeline.add(entry(items.get(iter)));
                shown++;
            }
        }
        if (agenda && shown == 0) {
            timeline.add(Ui.empty(FontImage.MATERIAL_EVENT_AVAILABLE, "Nothing booked",
                    "Appointments show here as clients book them."));
        }
        Layouts.broaden(timeline);
        Ui.relayout(timeline);
    }

    /// The day or the week as a timeline: a lane per day, the shifts as bands,
    /// bookings as blocks a tap opens, calendar busy time hatched.
    private Timeline grid(ScheduleDto s) {
        List<Timeline.Lane> lanes = new ArrayList<Timeline.Lane>();
        for (int d = 0; d < days(); d++) {
            long dayFrom = Times.addDays(from, d);
            Timeline.Lane lane = new Timeline.Lane(Times.shortDay(dayFrom), dayFrom);
            List<Object> items = items(s, dayFrom, Times.addDays(from, d + 1), false);
            for (int iter = 0; iter < items.size(); iter++) {
                lane.add(block(items.get(iter)));
            }
            lanes.add(lane);
        }
        Timeline t = new Timeline(lanes, days() > 1);
        t.setName("timeline-grid");
        return t;
    }

    private Timeline.Block block(Object o) {
        if (o instanceof ShiftDto) {
            ShiftDto shift = (ShiftDto) o;
            return new Timeline.Block(Timeline.SHIFT, shift.start(), shift.end(), "", "", null);
        }
        if (o instanceof BusyDto) {
            BusyDto b = (BusyDto) o;
            String what = "TIME_OFF".equals(b.kind()) ? Lang.tr("Time off")
                    : b.label() == null || b.label().length() == 0 ? Lang.tr("Busy in a calendar")
                    : b.label();
            return new Timeline.Block(Timeline.BUSY, b.start(), b.end(), what, "", null);
        }
        final AppointmentDto a = (AppointmentDto) o;
        return new Timeline.Block(Timeline.kindOf(a.state()), a.start(), a.end(),
                a.serviceName(), Times.clock(a.start()) + Ui.DOT
                        + (a.clientName() == null ? "" : a.clientName()), () -> booking(a));
    }

    /// Shifts, busy blocks and appointments that start in a day, in time order.
    private static List<Object> items(ScheduleDto s, long dayFrom, long dayTo, boolean agenda) {
        List<Object> out = new ArrayList<Object>();
        if (!agenda) {
            for (int iter = 0; iter < s.shifts().size(); iter++) {
                ShiftDto shift = s.shifts().get(iter);
                if (shift.start() >= dayFrom && shift.start() < dayTo) {
                    out.add(shift);
                }
            }
            for (int iter = 0; iter < s.busy().size(); iter++) {
                BusyDto b = s.busy().get(iter);
                if (b.start() < dayTo && b.end() > dayFrom) {
                    out.add(b);
                }
            }
        }
        for (int iter = 0; iter < s.appointments().size(); iter++) {
            AppointmentDto a = s.appointments().get(iter);
            if (a.start() >= dayFrom && a.start() < dayTo) {
                out.add(a);
            }
        }
        // Insertion sort: a day holds a handful of entries.
        for (int i = 1; i < out.size(); i++) {
            Object moving = out.get(i);
            int j = i - 1;
            while (j >= 0 && start(out.get(j)) > start(moving)) {
                out.set(j + 1, out.get(j));
                j--;
            }
            out.set(j + 1, moving);
        }
        return out;
    }

    private static long start(Object o) {
        if (o instanceof AppointmentDto) {
            return ((AppointmentDto) o).start();
        }
        if (o instanceof BusyDto) {
            return ((BusyDto) o).start();
        }
        return o instanceof ShiftDto ? ((ShiftDto) o).start() : 0;
    }

    private Container entry(Object o) {
        if (o instanceof ShiftDto) {
            ShiftDto shift = (ShiftDto) o;
            return Ui.row(FontImage.MATERIAL_SCHEDULE, Lang.tr("Working"),
                    Times.clock(shift.start()) + " - " + Times.clock(shift.end()), null);
        }
        if (o instanceof BusyDto) {
            BusyDto b = (BusyDto) o;
            String what = "TIME_OFF".equals(b.kind()) ? Lang.tr("Time off")
                    : b.label() == null || b.label().length() == 0 ? Lang.tr("Busy in a calendar")
                    : b.label();
            Container row = Ui.row(FontImage.MATERIAL_BLOCK, what,
                    Times.clock(b.start()) + " - " + Times.clock(b.end()), null);
            row.setUIID("SvBusyRow");
            return row;
        }
        final AppointmentDto a = (AppointmentDto) o;
        String who = a.clientName() == null ? "" : a.clientName();
        return Ui.tap(Ui.row(FontImage.MATERIAL_EVENT, a.serviceName(),
                Times.clock(a.start()) + " - " + Times.clock(a.end()) + Ui.DOT + who + Ui.DOT
                        + Appointments.state(a), null),
                "booking-" + a.id(), e -> booking(a));
    }

    /// One booking from the provider's side: who, and how it ended.
    private void booking(final AppointmentDto a) {
        final Form shown = Ui.form(a.serviceName(), "Booking");
        Ui.back(shown, form);
        Container page = Ui.page();
        page.add(Ui.label(Ui.weekday(a.start()), "SvTitle"));
        page.add(Ui.text(Times.clock(a.start()) + " - " + Times.clock(a.end()), "SvHeading"));
        page.add(Ui.fact("Client", a.clientName()));
        page.add(Ui.fact("Where", a.online() ? Lang.tr("Online") : a.locationName()));
        page.add(Ui.fact("State", Appointments.state(a)));
        page.add(Ui.fact("Payment", Appointments.payment(a)));
        if (a.note() != null && a.note().length() > 0) {
            page.add(Ui.fact("Note", a.note()));
        }
        if (Appointments.CONFIRMED.equals(a.state())) {
            long now = System.currentTimeMillis();
            if (now >= a.start()) {
                page.add(Ui.primary("Mark as done", "complete", e -> Api.provider().complete(a.id(),
                        Net.to(done -> form.showBack(), Ui::fail))));
                page.add(Ui.secondary("Did not come", "no-show", e -> Api.provider().noShow(a.id(),
                        Net.to(done -> form.showBack(), Ui::fail))));
            }
            page.add(Ui.danger("Cancel", "cancel", e -> Ui.reason(shown, "Cancel booking",
                    Lang.tr("The client is told at once and refunded in full."), "Cancel booking",
                    why -> Api.provider().cancel(a.id(), new CancelDto(why),
                            Net.to(done -> form.showBack(), Ui::fail)))));
        }
        Layouts.broaden(page);
        shown.add(BorderLayout.CENTER, page);
        shown.show();
    }
}
/// Time running down the page, one lane per day or per person: the hours a
/// provider works as a band, bookings as blocks as long as they last, busy
/// time from a calendar hatched, and a line at the current time.
///
/// It is the provider's day (one lane), their week (seven) and the owner's
/// board (a lane per provider): the same picture, so a gap reads the same
/// everywhere. Colours come from `theme.css` -- `SvTimeline` for the grid and
/// the closed hours, `SvTimelineShift`, `SvTimelineBusy`, `SvTimelineBooking`
/// (and `SvTimelineDone` for one that is over), `SvTimelineNow` -- so dark
/// mode is a stylesheet change, as it is for every other screen.
public final class Timeline extends Component {
    /// A working band: drawn under everything else.
    public static final int SHIFT = 0;
    /// Busy in a calendar, or time off.
    public static final int BUSY = 1;
    /// A booking.
    public static final int BOOKING = 2;
    /// A booking that is over and was kept.
    public static final int DONE = 3;
    /// A booking the client did not come to.
    public static final int NO_SHOW = 4;

    private static final float HOUR_MM = 13f;
    private static final float GUTTER_MM = 10f;
    private static final float HEADER_MM = 9f;
    private static final float MIN_LANE_MM = 22f;
    private static final long HOUR = 3600000L;

    /// Something to draw. `start` and `end` are epoch milliseconds.
    public static final class Block {
        final int kind;
        final long start;
        final long end;
        final String title;
        final String detail;
        final Runnable tap;

        public Block(int kind, long start, long end, String title, String detail, Runnable tap) {
            this.kind = kind;
            this.start = start;
            this.end = end;
            this.title = title == null ? "" : title;
            this.detail = detail == null ? "" : detail;
            this.tap = tap;
        }
    }

    /// One column: a day or a person, starting at `dayStart` (midnight).
    public static final class Lane {
        final String title;
        final long dayStart;
        final List<Block> blocks = new ArrayList<Block>();

        public Lane(String title, long dayStart) {
            this.title = title == null ? "" : title;
            this.dayStart = dayStart;
        }

        public Lane add(Block b) {
            blocks.add(b);
            return this;
        }

        /// How many bookings the lane shows, for a test.
        public int bookings() {
            int n = 0;
            for (int iter = 0; iter < blocks.size(); iter++) {
                int kind = blocks.get(iter).kind;
                if (booking(kind)) {
                    n++;
                }
            }
            return n;
        }
    }

    private final List<Lane> lanes;
    private final boolean headers;
    private int firstHour = 24;
    private int lastHour = 0;

    /// The kind of block an appointment in `state` draws as.
    public static int kindOf(String state) {
        return "COMPLETED".equals(state) ? DONE : "NO_SHOW".equals(state) ? NO_SHOW : BOOKING;
    }

    static boolean booking(int kind) {
        return kind == BOOKING || kind == DONE || kind == NO_SHOW;
    }

    /// The key to the colors, one row: what a booking, a finished one, a no-show
    /// and busy time look like. Put it under the timeline it explains.
    public static Container legend() {
        Container row = new Container(new FlowLayout());
        row.setUIID("SvLegend");
        String[][] keys = {{"SvTimelineBooking", "Booked"}, {"SvTimelineDone", "Done"},
            {"SvTimelineNoShow", "No-show"}, {"SvTimelineBusy", "Busy"}};
        for (int iter = 0; iter < keys.length; iter++) {
            Label swatch = new Label(" ", keys[iter][0]);
            swatch.getAllStyles().setPadding(0, 0, 0, 0);
            swatch.getAllStyles().setMargin(0, 0, iter == 0 ? 0 : CN.convertToPixels(3f),
                    CN.convertToPixels(1f));
            swatch.setPreferredW(CN.convertToPixels(3f));
            swatch.setPreferredH(CN.convertToPixels(3f));
            row.add(swatch);
            row.add(new Label(Lang.tr(keys[iter][1]), "SvCaption"));
        }
        return row;
    }

    /// @param headers whether each lane's title is drawn above it
    public Timeline(List<Lane> lanes, boolean headers) {
        this.lanes = lanes;
        this.headers = headers;
        setUIID("SvTimeline");
        setFocusable(false);
        fitHours();
    }

    public List<Lane> lanes() {
        return lanes;
    }

    /// The hours shown: exactly the hours something is in -- shifts included,
    /// so a day opens on working time rather than on the closed early morning
    /// -- and at least six of them; 09 to 18 when there is nothing at all.
    private void fitHours() {
        for (int l = 0; l < lanes.size(); l++) {
            Lane lane = lanes.get(l);
            for (int iter = 0; iter < lane.blocks.size(); iter++) {
                Block b = lane.blocks.get(iter);
                long from = Math.max(0, b.start - lane.dayStart);
                long to = Math.min(24 * HOUR, b.end - lane.dayStart);
                if (to <= 0 || from >= 24 * HOUR) {
                    continue;
                }
                firstHour = Math.min(firstHour, (int) (from / HOUR));
                lastHour = Math.max(lastHour, (int) ((to + HOUR - 1) / HOUR));
            }
        }
        if (lastHour <= firstHour) {
            firstHour = 9;
            lastHour = 18;
        }
        while (lastHour - firstHour < 6) {
            if (lastHour < 24) {
                lastHour++;
            } else {
                firstHour--;
            }
        }
    }

    private int hourPx() {
        return CN.convertToPixels(HOUR_MM);
    }

    private int gutter() {
        return CN.convertToPixels(GUTTER_MM);
    }

    private int header() {
        return headers ? CN.convertToPixels(HEADER_MM) : CN.convertToPixels(2f);
    }

    @Override
    protected Dimension calcPreferredSize() {
        int lanesWide = Math.max(1, lanes.size()) * CN.convertToPixels(MIN_LANE_MM);
        return new Dimension(gutter() + lanesWide,
                header() + (lastHour - firstHour) * hourPx() + CN.convertToPixels(2f));
    }

    private int laneWidth() {
        return Math.max(1, (getWidth() - gutter()) / Math.max(1, lanes.size()));
    }

    private int yOf(Lane lane, long time) {
        double hours = (time - lane.dayStart) / (double) HOUR - firstHour;
        hours = Math.max(0, Math.min(lastHour - firstHour, hours));
        return getY() + header() + (int) Math.round(hours * hourPx());
    }

    @Override
    public void paint(Graphics g) {
        UIManager styles = getUIManager();
        Style grid = getStyle();
        Font small = styles.getComponentStyle("SvChartAxis").getFont();
        int x0 = getX() + gutter();
        int top = getY() + header();
        int bottom = top + (lastHour - firstHour) * hourPx();
        int lane = laneWidth();
        int pad = CN.convertToPixels(0.6f);
        g.setAntiAliased(true);
        g.setAntiAliasedText(true);

        // Closed hours are the grid's own background; a shift paints over it.
        g.setColor(grid.getBgColor());
        g.fillRect(x0, top, lane * lanes.size(), bottom - top);
        for (int l = 0; l < lanes.size(); l++) {
            Lane ln = lanes.get(l);
            int lx = x0 + l * lane;
            fill(g, ln, SHIFT, lx, lane, 0, styles.getComponentStyle("SvTimelineShift").getBgColor());
        }

        // Hour lines and labels.
        g.setFont(small);
        for (int h = firstHour; h <= lastHour; h++) {
            int y = top + (h - firstHour) * hourPx();
            g.setColor(grid.getFgColor());
            g.drawLine(x0, y, x0 + lane * lanes.size(), y);
            if (h < lastHour) {
                g.setColor(styles.getComponentStyle("SvChartAxis").getFgColor());
                String text = (h < 10 ? "0" : "") + h + ":00";
                g.drawString(text, getX() + gutter() - small.stringWidth(text) - pad,
                        y - small.getHeight() / 2 < getY() ? y : y - small.getHeight() / 2);
            }
        }
        for (int l = 0; l <= lanes.size(); l++) {
            g.setColor(grid.getFgColor());
            g.drawLine(x0 + l * lane, top, x0 + l * lane, bottom);
        }

        Font titleFont = styles.getComponentStyle("SvTimelineBooking").getFont();
        for (int l = 0; l < lanes.size(); l++) {
            Lane ln = lanes.get(l);
            int lx = x0 + l * lane;
            if (headers) {
                header(g, ln, lx, lane, pad, small, styles);
            }
            Style busy = styles.getComponentStyle("SvTimelineBusy");
            fill(g, ln, BUSY, lx, lane, pad, busy.getBgColor());
            for (int iter = 0; iter < ln.blocks.size(); iter++) {
                Block b = ln.blocks.get(iter);
                if (!booking(b.kind)) {
                    if (b.kind == BUSY) {
                        hatch(g, ln, b, lx + pad, lane - 2 * pad, busy.getFgColor(), pad);
                        label(g, ln, b, lx, lane, pad, busy.getFgColor(), small, small);
                    }
                    continue;
                }
                Style s = styles.getComponentStyle(b.kind == DONE ? "SvTimelineDone"
                        : b.kind == NO_SHOW ? "SvTimelineNoShow" : "SvTimelineBooking");
                int y1 = yOf(ln, b.start);
                int y2 = Math.max(y1 + small.getHeight(), yOf(ln, b.end));
                g.setColor(s.getBgColor());
                g.fillRoundRect(lx + pad, y1 + 1, lane - 2 * pad, y2 - y1 - 2, 3 * pad, 3 * pad);
                g.setColor(s.getFgColor());
                g.fillRect(lx + pad, y1 + 1, Math.max(2, pad), y2 - y1 - 2);
                label(g, ln, b, lx + pad, lane - pad, pad, s.getFgColor(), titleFont, small);
            }
        }

        // Now, where it falls inside a lane's day.
        long now = System.currentTimeMillis();
        g.setColor(styles.getComponentStyle("SvTimelineNow").getFgColor());
        for (int l = 0; l < lanes.size(); l++) {
            Lane ln = lanes.get(l);
            long into = now - ln.dayStart;
            if (into >= firstHour * HOUR && into < lastHour * HOUR) {
                int y = yOf(ln, now);
                int lx = x0 + l * lane;
                g.fillRect(lx, y - 1, lane, Math.max(2, pad / 2));
                g.fillArc(lx - pad, y - pad, 2 * pad, 2 * pad, 0, 360);
            }
        }
    }

    // A lane's title above it. When the lanes are days, today's is in the
    // now-line's color, so the week says which day it is. Titles too wide for a
    // phone's week lane ("Thu 5") all go on two lines, centred, rather than
    // being cut to "Th..." -- all of them, or the row reads ragged.
    private void header(Graphics g, Lane ln, int lx, int lane, int pad, Font font,
                        UIManager styles) {
        long now = System.currentTimeMillis();
        boolean today = days() && now >= ln.dayStart && now < ln.dayStart + 24 * HOUR;
        g.setColor(styles.getComponentStyle(today ? "SvTimelineNow" : "SvCaption").getFgColor());
        g.setFont(font);
        int room = lane - 2 * pad;
        int space = ln.title.indexOf(' ');
        if (!wraps(font, room) || space < 0) {
            String text = clip(font, ln.title, room);
            g.drawString(text, lx + (lane - font.stringWidth(text)) / 2,
                    getY() + (header() - font.getHeight()) / 2);
            return;
        }
        String first = clip(font, ln.title.substring(0, space), room);
        String second = clip(font, ln.title.substring(space + 1), room);
        int y = getY() + (header() - 2 * font.getHeight()) / 2;
        g.drawString(first, lx + (lane - font.stringWidth(first)) / 2, y);
        g.drawString(second, lx + (lane - font.stringWidth(second)) / 2, y + font.getHeight());
    }

    // Whether the lanes are days rather than people on one day.
    private boolean days() {
        for (int iter = 1; iter < lanes.size(); iter++) {
            if (lanes.get(iter).dayStart != lanes.get(0).dayStart) {
                return true;
            }
        }
        return false;
    }

    private boolean wraps(Font font, int room) {
        for (int iter = 0; iter < lanes.size(); iter++) {
            if (font.stringWidth(lanes.get(iter).title) > room) {
                return true;
            }
        }
        return false;
    }

    private void fill(Graphics g, Lane ln, int kind, int lx, int lane, int pad, int color) {
        g.setColor(color);
        for (int iter = 0; iter < ln.blocks.size(); iter++) {
            Block b = ln.blocks.get(iter);
            if (b.kind == kind) {
                int y1 = yOf(ln, b.start);
                int y2 = yOf(ln, b.end);
                if (y2 > y1) {
                    g.fillRect(lx + pad, y1, lane - 2 * pad, y2 - y1);
                }
            }
        }
    }

    // A block's words: the title, then the detail's parts (time, client,
    // state) a line each while there is height for them, the rest joined on
    // the last line. A block too narrow for its title shows its start time,
    // so a phone's week still reads at a glance.
    private void label(Graphics g, Lane ln, Block b, int lx, int width, int pad, int color,
                       Font titleFont, Font detailFont) {
        int y1 = yOf(ln, b.start);
        int y2 = yOf(ln, b.end);
        int room = width - 3 * pad;
        if (room <= 0 || y2 - y1 < detailFont.getHeight()) {
            return;
        }
        g.setColor(color);
        String title = clip(titleFont, b.title, room);
        if (title.length() == 0 && b.title.length() > 0) {
            long into = (b.start - ln.dayStart) / 60000L;
            String clock = (into / 60) + ":" + (into % 60 < 10 ? "0" : "") + (into % 60);
            if (detailFont.stringWidth(clock) > room) {
                clock = String.valueOf(into / 60);
            }
            if (detailFont.stringWidth(clock) <= room + 2 * pad) {
                g.setFont(detailFont);
                g.drawString(clock, lx + pad + Math.max(0, (width - pad - detailFont.stringWidth(clock)) / 2),
                        y1 + pad);
            }
            return;
        }
        if (y2 - y1 < titleFont.getHeight()) {
            return;
        }
        g.setFont(titleFont);
        g.drawString(title, lx + 2 * pad, y1 + pad);
        if (b.detail.length() == 0) {
            return;
        }
        List<String> parts = new ArrayList<String>();
        String rest = b.detail;
        int at = rest.indexOf(Ui.DOT);
        while (at >= 0) {
            parts.add(rest.substring(0, at));
            rest = rest.substring(at + Ui.DOT.length());
            at = rest.indexOf(Ui.DOT);
        }
        parts.add(rest);
        int y = y1 + pad + titleFont.getHeight();
        g.setFont(detailFont);
        for (int iter = 0; iter < parts.size(); iter++) {
            if (y + detailFont.getHeight() > y2 - pad / 2) {
                return;
            }
            String line = parts.get(iter);
            boolean last = y + 2 * detailFont.getHeight() > y2 - pad / 2;
            if (last) {
                for (int more = iter + 1; more < parts.size(); more++) {
                    line += Ui.DOT + parts.get(more);
                }
            }
            g.drawString(clip(detailFont, line, room), lx + 2 * pad, y);
            if (last) {
                return;
            }
            y += detailFont.getHeight();
        }
    }

    // Busy time is hatched, so it never reads as a booking that lost its color.
    private void hatch(Graphics g, Lane ln, Block b, int x, int w, int color, int pad) {
        int y1 = yOf(ln, b.start);
        int y2 = yOf(ln, b.end);
        if (y2 <= y1 || w <= 0) {
            return;
        }
        int[] clip = g.getClip();
        g.clipRect(x, y1, w, y2 - y1);
        g.setColor(color);
        g.setAlpha(70);
        int step = Math.max(4, 2 * pad);
        for (int off = -(y2 - y1); off < w; off += step) {
            g.drawLine(x + off, y2, x + off + (y2 - y1), y1);
        }
        g.setAlpha(255);
        g.setClip(clip);
    }

    private static String clip(Font f, String text, int width) {
        if (f.stringWidth(text) <= width) {
            return text;
        }
        String dots = "...";
        int n = text.length();
        while (n > 0 && f.stringWidth(text.substring(0, n) + dots) > width) {
            n--;
        }
        // "C..." says nothing; a narrow block is better left to its color.
        return n < 3 ? "" : text.substring(0, n) + dots;
    }

    @Override
    public void pointerReleased(int x, int y) {
        super.pointerReleased(x, y);
        Block b = blockAt(x - getAbsoluteX() + getX(), y - getAbsoluteY() + getY());
        if (b != null && b.tap != null) {
            b.tap.run();
        }
    }

    /// The booking under a point in this component's parent's coordinates.
    Block blockAt(int x, int y) {
        int lane = laneWidth();
        int l = (x - getX() - gutter()) / lane;
        if (x < getX() + gutter() || l < 0 || l >= lanes.size()) {
            return null;
        }
        Lane ln = lanes.get(l);
        for (int iter = 0; iter < ln.blocks.size(); iter++) {
            Block b = ln.blocks.get(iter);
            if ((b.kind == BOOKING || b.kind == DONE) && y >= yOf(ln, b.start) && y <= yOf(ln, b.end)) {
                return b;
            }
        }
        return null;
    }
}

Calendars, both ways

A provider’s other commitments keep clients from booking over them, and their bookings appear in the calendar they already use. Slate reads busy time from four kinds of calendar and writes in two ways.

The phone’s own calendars are read by the app, never the server: DeviceSync reads the events of the calendars the provider chose and uploads only busy intervals, so the server never sees what an event is called.

/// The phone's own calendars, through `com.codename1.calendar`.
///
/// Three things are asked of them, and nothing else:
///
/// - **busy time**, as free/busy intervals and never as events: a client is
///   warned that a slot clashes with their own day, and a provider's busy time
///   is uploaded so nobody books over it. A title, a place or a guest never
///   leaves this class.
/// - **adding an appointment**, with an alarm ahead of it, once it is booked.
/// - **keeping that event true** when the appointment moves or is cancelled.
///
/// Each asks for access the first time it needs it, which is when the user
/// has just tapped the thing that needs it: the platform's prompt then makes
/// sense. Every answer arrives on the EDT.
public final class DeviceCalendar {
    /// What the phone remembers about an appointment it added: the calendar
    /// and event ids as `calendar|event`, keyed by appointment id, so a later
    /// change finds the same event.
    private static final String EVENT_PREFIX = "slate.event.";
    private static final String INDEX = "slate.events";

    /// The answer to a question about the calendars.
    public interface Answer<T> {
        void answer(T value);
    }

    private DeviceCalendar() {
    }

    private static LocalCalendarSource source() {
        return LocalCalendarSource.getInstance();
    }

    /// Whether this phone has calendars the app can read busy time from.
    public static boolean canReadBusy() {
        LocalCalendarSource s = source();
        return s != null && s.isAvailable()
                && s.getCapabilities().supports(CalendarCapability.FREE_BUSY);
    }

    /// Whether this phone can have events added to it.
    public static boolean canWrite() {
        LocalCalendarSource s = source();
        return s != null && s.isAvailable()
                && s.getCapabilities().supports(CalendarCapability.WRITE_EVENTS);
    }

    /// Whether the phone can tell the app when its calendars change, so busy
    /// time is uploaded again without waiting for the next sync.
    public static boolean canListen() {
        LocalCalendarSource s = source();
        return s != null && s.isAvailable()
                && s.getCapabilities().supports(CalendarCapability.LOCAL_CHANGE_LISTENER);
    }

    /// Asks for `access` when it was never asked, and answers whether it is held.
    public static void authorize(final CalendarAccess access, final Answer<Boolean> done) {
        final LocalCalendarSource s = source();
        if (s == null || !s.isAvailable()) {
            done.answer(Boolean.FALSE);
            return;
        }
        CalendarAuthorizationStatus now = s.getAuthorizationStatus(access);
        if (granted(now, access)) {
            done.answer(Boolean.TRUE);
            return;
        }
        if (now != CalendarAuthorizationStatus.NOT_DETERMINED) {
            done.answer(Boolean.FALSE);
            return;
        }
        s.requestAuthorization(access)
                .ready(status -> later(() -> done.answer(Boolean.valueOf(granted(status, access)))))
                .except(failed -> later(() -> done.answer(Boolean.FALSE)));
    }

    private static boolean granted(CalendarAuthorizationStatus status, CalendarAccess access) {
        if (status == CalendarAuthorizationStatus.FULL) {
            return true;
        }
        return status == CalendarAuthorizationStatus.WRITE_ONLY
                && access == CalendarAccess.EVENTS_WRITE_ONLY;
    }

    /// The calendars on the phone, for a provider choosing which count as busy.
    public static void calendars(final Answer<List<CalendarInfo>> done) {
        authorize(CalendarAccess.EVENTS_READ_ONLY, ok -> {
            if (!ok.booleanValue()) {
                done.answer(new ArrayList<CalendarInfo>());
                return;
            }
            source().listCalendars(CalendarInfo.ContentType.EVENTS, null)
                    .ready(page -> later(() -> done.answer(items(page))))
                    .except(failed -> later(() -> done.answer(new ArrayList<CalendarInfo>())));
        });
    }

    private static List<CalendarInfo> items(CalendarPage<CalendarInfo> page) {
        List<CalendarInfo> out = new ArrayList<CalendarInfo>();
        if (page != null && page.getItems() != null) {
            out.addAll(page.getItems());
        }
        return out;
    }

    /// Busy time between `from` and `to` in the calendars named by `ids`, or
    /// in every calendar when `ids` is empty, as `{start, end}` millis. Time
    /// marked free does not count. Empty when access is refused.
    public static void busy(final List<String> ids, final long from, final long to,
            final Answer<List<long[]>> done) {
        authorize(CalendarAccess.EVENTS_READ_ONLY, ok -> {
            if (!ok.booleanValue()) {
                done.answer(new ArrayList<long[]>());
                return;
            }
            if (ids != null && ids.size() > 0) {
                query(ids, from, to, done);
                return;
            }
            calendars(all -> {
                List<String> every = new ArrayList<String>();
                for (int iter = 0; iter < all.size(); iter++) {
                    every.add(all.get(iter).getId());
                }
                query(every, from, to, done);
            });
        });
    }

    private static void query(List<String> ids, long from, long to,
            final Answer<List<long[]>> done) {
        if (ids.isEmpty()) {
            done.answer(new ArrayList<long[]>());
            return;
        }
        source().queryFreeBusy(ids, Instant.ofEpochMilli(from), Instant.ofEpochMilli(to))
                .ready(list -> later(() -> done.answer(spans(list))))
                .except(failed -> later(() -> done.answer(new ArrayList<long[]>())));
    }

    private static List<long[]> spans(List<FreeBusyInterval> list) {
        List<long[]> out = new ArrayList<long[]>();
        for (int iter = 0; list != null && iter < list.size(); iter++) {
            FreeBusyInterval i = list.get(iter);
            if (i.getAvailability() == CalendarEvent.Availability.FREE) {
                continue;
            }
            out.add(new long[] {i.getStartTime().toEpochMilli(), i.getEndTime().toEpochMilli()});
        }
        return out;
    }
/// Sends the server when the provider is busy in the phone's own calendars.
///
/// Only intervals leave the phone: a start and an end, no title, no
/// attendees, nothing that says what the time is for. The server stores them
/// against the provider's `DEVICE` link and the slot engine treats them as it
/// treats a Google or Microsoft calendar's busy blocks.
///
/// It uploads the next `DAYS` days when the provider's home comes forward,
/// and again whenever the calendar changes underneath, on a platform that
/// can say so (`LOCAL_CHANGE_LISTENER`).
public final class DeviceSync {
    static final String LINK = "slate.device.link";
    static final String CALENDARS = "slate.device.calendars";
    private static final int DAYS = 30;
    private static CalendarChangeListener listening;

    private DeviceSync() {
    }

    /// Starts keeping `linkId` up to date from `calendars`, a comma separated
    /// list of the phone's calendar ids.
    public static void start(String linkId, String calendars) {
        Preferences.set(LINK, linkId);
        Preferences.set(CALENDARS, calendars);
        resume();
    }

    /// Uploads once, and listens for changes, if a device link is set up.
    public static void resume() {
        if (Preferences.get(LINK, "").length() == 0 || !DeviceCalendar.canReadBusy()) {
            return;
        }
        upload();
        if (listening == null && DeviceCalendar.canListen()) {
            listening = change -> upload();
            LocalCalendarSource.getInstance().addChangeListener(listening);
        }
    }

    /// Stops listening. Signing out, or disconnecting the link.
    public static void stop() {
        if (listening != null) {
            LocalCalendarSource.getInstance().removeChangeListener(listening);
            listening = null;
        }
    }

    /// Forgets the link: the next provider on this phone starts afresh.
    public static void forget() {
        stop();
        Preferences.delete(LINK);
        Preferences.delete(CALENDARS);
    }

    static void upload() {
        final String link = Preferences.get(LINK, "");
        final long from = Times.dayStart(System.currentTimeMillis());
        final long to = Times.addDays(from, DAYS);
        DeviceCalendar.busy(split(Preferences.get(CALENDARS, "")), from, to, busy -> {
            List<IntervalDto> intervals = new ArrayList<IntervalDto>();
            for (int iter = 0; iter < busy.size(); iter++) {
                long[] span = busy.get(iter);
                intervals.add(new IntervalDto(span[0], span[1]));
            }
            final int count = intervals.size();
            Api.provider().uploadBusy(new BusyUploadDto(link, from, to, intervals),
                    Net.to(done -> Telemetry.busyUploaded(count), (status, message) -> {
                    }));
        });
    }
/// What the app read from the phone's calendars: busy intervals over
/// `from`..`to`, which replace what the link said about that stretch.
public CalendarLinkDto upload(String username, BusyUploadDto busy) throws IOException {
    if (busy == null || busy.busy() == null || busy.to() <= busy.from()) {
        throw new ResponseStatusException(400, "Nothing to upload");
    }
    if (busy.to() - busy.from() > AHEAD + DAY || busy.busy().size() > MAX_INTERVALS) {
        throw new ResponseStatusException(400, "Upload up to four months at a time");
    }
    List<long[]> spans = new ArrayList<long[]>();
    for (int iter = 0; iter < busy.busy().size(); iter++) {
        IntervalDto i = busy.busy().get(iter);
        if (i != null && i.end() > i.start()) {
            spans.add(new long[] {Math.max(i.start(), busy.from()),
                Math.min(i.end(), busy.to() + DAY)});
        }
    }
    CalendarLinkDto done = store(username, busy.linkId(), true, busy.from(), busy.to(),
            spans, "");
    changed(username);
    return done;
}

Google and Microsoft calendars are read by the server, with the refresh token the provider granted, so the schedule stays right while the phone is off. Slate asks for busy times only, never the events:

@Override
public List<long[]> busy(String credential, String calendars, long from, long to)
        throws IOException {
    List<Map<String, Object>> items = new ArrayList<Map<String, Object>>();
    List<String> ids = calendars(calendars);
    for (int iter = 0; iter < ids.size(); iter++) {
        Map<String, Object> item = new HashMap<String, Object>();
        item.put("id", ids.get(iter));
        items.add(item);
    }
    Map<String, Object> ask = new HashMap<String, Object>();
    ask.put("timeMin", Instant.ofEpochMilli(from).toString());
    ask.put("timeMax", Instant.ofEpochMilli(to).toString());
    ask.put("items", items);
    Map answer = call("POST", FREE_BUSY, access(credential), Json.write(ask));
    List<long[]> out = new ArrayList<long[]>();
    Object all = answer.get("calendars");
    if (!(all instanceof Map)) {
        return out;
    }
    for (Object one : ((Map) all).values()) {
        if (!(one instanceof Map) || !(((Map) one).get("busy") instanceof List)) {
            continue;
        }
        List busy = (List) ((Map) one).get("busy");
        for (int iter = 0; iter < busy.size(); iter++) {
            if (busy.get(iter) instanceof Map) {
                Map b = (Map) busy.get(iter);
                long[] span = span(b.get("start"), b.get("end"));
                if (span != null) {
                    out.add(span);
                }
            }
        }
    }
    return out;
}
/// Connects a calendar. For a server-read one the code or address is
/// turned into a credential first, with no transaction open, and the
/// calendar read once at once so the schedule shows it straight away.
public CalendarLinkDto connect(String username, ConnectCalendarDto ask) throws IOException {
    if (ask == null) {
        throw new ResponseStatusException(400, "Nothing to connect");
    }
    String kind = ask.kind() == null ? "" : ask.kind().trim();
    String credential = "";
    if (!DEVICE.equals(kind)) {
        ExternalCalendar source = sources.of(kind);
        if (source == null) {
            throw new ResponseStatusException(400, kind.length() == 0
                    ? "Say which calendar" : "This server is not set up for " + kind);
        }
        try {
            credential = source.connect(ask.credential(), ask.redirectUri());
        } catch (IOException refused) {
            throw new ResponseStatusException(400, refused.getMessage());
        }
    }
    CalendarLink link = add(username, kind, ask, credential);
    if (!DEVICE.equals(kind)) {
        return sync(username, link.getId());
    }
    return dto(link);
}
/// Asks for read access, lists the phone's calendars and lets the
/// provider pick which count. A work calendar and a family one may be
/// on the phone; a birthday calendar should not block a morning.
private void device() {
    DeviceCalendar.authorize(CalendarAccess.EVENTS_READ_ONLY, ok -> {
        if (!ok.booleanValue()) {
            Ui.say("Calendar access is off. It can be turned on in Settings.");
            return;
        }
        DeviceCalendar.calendars(this::pick);
    });
}

private void pick(final List<CalendarInfo> calendars) {
    final Form choose = Ui.form("This phone", "DeviceCalendars");
    Ui.back(choose, form);
    Container list = Ui.page();
    final List<CheckBox> boxes = new ArrayList<CheckBox>();
    for (int iter = 0; iter < calendars.size(); iter++) {
        CheckBox box = CheckBox.createToggle(calendars.get(iter).getName());
        box.setUIID("SvCheck");
        box.setName("calendar-" + iter);
        box.setSelected(true);
        boxes.add(box);
        list.add(box);
    }
    list.add(Ui.primary("Use these calendars", "use", e -> {
        StringBuilder ids = new StringBuilder();
        for (int iter = 0; iter < boxes.size(); iter++) {
            if (boxes.get(iter).isSelected()) {
                if (ids.length() > 0) {
                    ids.append(',');
                }
                ids.append(calendars.get(iter).getId());
            }
        }
        final String chosen = ids.toString();
        Api.provider().connect(new ConnectCalendarDto("DEVICE", Lang.tr("This phone"), "", "",
                chosen, ""), Net.to(link -> {
                    Telemetry.calendarConnected("DEVICE");
                    DeviceSync.start(link.id(), chosen);
                    form.showBack();
                    load();
                }, Ui::fail));
    }));
    Layouts.broaden(list);
    choose.add(BorderLayout.CENTER, list);
    choose.show();
}

The development profile simulates both services. On a real deployment the choice is made from configuration:

/// The calendars the server reads for providers.
///
/// - Google: `slate.calendar.google.id` and `slate.calendar.google.secret`,
///   an OAuth client of type "Web application" with the Calendar API on and
///   the `calendar.freebusy` scope.
/// - Microsoft: `slate.calendar.microsoft.id` and
///   `slate.calendar.microsoft.secret`, an Entra app registration with the
///   delegated `Calendars.ReadBasic` and `offline_access` permissions.
/// - A published iCalendar feed needs nothing.
///
/// On a development profile a kind with no client is simulated, so the
/// demo connects calendars without an account anywhere. Elsewhere it is
/// not offered. `slate.timezone` is the zone a feed's all-day events and
/// the simulated busy time are read in.
@Bean
public CalendarSources calendarSources(Config config,
        @Value("${slate.calendar.google.id:}") String googleId,
        @Value("${slate.calendar.google.secret:}") String googleSecret,
        @Value("${slate.calendar.microsoft.id:}") String microsoftId,
        @Value("${slate.calendar.microsoft.secret:}") String microsoftSecret,
        @Value("${slate.timezone:Europe/London}") String zone) {
    return new CalendarSources(googleId.trim(), googleSecret.trim(), microsoftId.trim(),
            microsoftSecret.trim(), config.isDevelopmentProfile(), Catalog.zone(zone));
}

A published ICS address is fetched on a schedule, the same way.

Writing goes the other way. Every provider has a calendar feed any calendar app can subscribe to, and every appointment has an .ics file a client can open:

/// A whole calendar of `events`, with CRLF line ends and long lines folded.
public static String write(String name, List<Event> events) {
    StringBuilder out = new StringBuilder();
    line(out, "BEGIN:VCALENDAR");
    line(out, "VERSION:2.0");
    line(out, "PRODID:-//Codename One//Slate//EN");
    line(out, "CALSCALE:GREGORIAN");
    line(out, "METHOD:PUBLISH");
    line(out, "X-WR-CALNAME:" + text(name));
    String stamp = UTC.format(Instant.now());
    for (int iter = 0; iter < events.size(); iter++) {
        Event e = events.get(iter);
        line(out, "BEGIN:VEVENT");
        line(out, "UID:" + e.uid + "@slate");
        line(out, "DTSTAMP:" + stamp);
        line(out, "DTSTART:" + UTC.format(Instant.ofEpochMilli(e.start)));
        line(out, "DTEND:" + UTC.format(Instant.ofEpochMilli(e.end)));
        line(out, "SUMMARY:" + text(e.summary));
        if (e.description.length() > 0) {
            line(out, "DESCRIPTION:" + text(e.description));
        }
        if (e.location.length() > 0) {
            line(out, "LOCATION:" + text(e.location));
        }
        if (e.url.length() > 0) {
            line(out, "URL:" + e.url);
        }
        line(out, "STATUS:" + (e.cancelled ? "CANCELLED" : "CONFIRMED"));
        if (e.alarmMinutes > 0 && !e.cancelled) {
            line(out, "BEGIN:VALARM");
            line(out, "ACTION:DISPLAY");
            line(out, "DESCRIPTION:" + text(e.summary));
            line(out, "TRIGGER:-PT" + e.alarmMinutes + "M");
            line(out, "END:VALARM");
        }
        line(out, "END:VEVENT");
    }
    line(out, "END:VCALENDAR");
    return out.toString();
}

A client can also add a booking straight to their phone’s calendar. The app keeps the ids of the events it wrote, so moving a booking moves its own event and a cancellation removes it:

/// Adds `a` to the phone's default calendar with an alarm `alarmMinutes`
/// ahead, or moves the event added for it before. Answers whether it is
/// there now.
public static void save(final AppointmentDto a, final String title, final int alarmMinutes,
        final Answer<Boolean> done) {
    authorize(CalendarAccess.EVENTS_WRITE_ONLY, ok -> {
        if (!ok.booleanValue()) {
            done.answer(Boolean.FALSE);
            return;
        }
        ZoneId zone = ZoneId.systemDefault();
        CalendarEvent event = new CalendarEvent()
                .setTitle(title)
                .setLocation(a.online() ? a.meetingUrl() : a.locationName())
                .setDescription(a.icsUrl() == null ? "" : a.icsUrl())
                .setStart(CalendarDateTime.instant(Instant.ofEpochMilli(a.start()), zone))
                .setEnd(CalendarDateTime.instant(Instant.ofEpochMilli(a.end()), zone))
                .setAvailability(CalendarEvent.Availability.BUSY)
                .putProviderData("slate.appointment", a.id());
        String known = eventOf(a.id());
        int bar = known.indexOf('|');
        if (bar >= 0) {
            event.setCalendarId(bar > 0 ? known.substring(0, bar) : null);
            event.setId(known.substring(bar + 1));
        }
        if (alarmMinutes > 0 && source().getCapabilities().supports(CalendarCapability.ALARMS)) {
            event.addAlarm(new CalendarAlarm().setTimeBefore(Duration.ofMinutes(alarmMinutes)));
        }
        source().saveEvent(event, CalendarMutationScope.THIS_INSTANCE)
                .ready(saved -> later(() -> {
                    if (saved != null && saved.getId() != null) {
                        String calendar = saved.getCalendarId() == null ? ""
                                : saved.getCalendarId();
                        Preferences.set(EVENT_PREFIX + a.id(), calendar + "|" + saved.getId());
                        index(a.id(), true);
                    }
                    done.answer(Boolean.TRUE);
                }))
                .except(failed -> later(() -> done.answer(Boolean.FALSE)));
    });
}

Deposits

A deposit is a part of the price, authorized on the card about six days before the appointment (at once when booking is closer than that) and taken when a client cancels late or doesn’t come. Payments go through the same PaymentProvider as the other samples:

public interface PaymentProvider {
    /// `simulated` or `stripe`: what the app is told, so it knows how to add a
    /// card.
    String name();

    /// Opens the processor's record of a customer and returns its reference.
    String createCustomer(String username, String displayName) throws IOException;

    /// Deletes the processor's record of a customer, and the cards saved to it
    /// with it. A record that is already gone is deleted: this is asked again
    /// when the first answer was lost.
    void forgetCustomer(String customer) throws IOException;

    /// Begins saving a card to `customer`. `setupId` is the server's own id
    /// for the attempt, for the processor to hand back.
    Setup startCardSetup(String customer, String setupId) throws IOException;

    /// Finishes a setup and returns the card it saved.
    ///
    /// @param reference what [#startCardSetup] returned as the setup's reference
    /// @param typed the card as typed into the app, for a processor whose setup
    ///     is not hosted; ignored by one whose setup is
    /// - `PaymentDeclinedException`: when the card was refused or the setup was
    ///   not finished
    Card finishCardSetup(String customer, String reference, CardDto typed) throws IOException;

    /// Takes a saved card off `customer`, so that it can't be charged again.
    /// A card that is already off, or gone, is detached: this is asked again
    /// when the first answer was lost.
    ///
    /// @param token what [#finishCardSetup] returned as the card's token
    void detachCard(String customer, String token) throws IOException;

    /// Charges a saved card with its owner absent.
    ///
    /// @param key names this charge and no other. A processor asked twice with
    ///     the same key charges once, so a request repeated after a timeout
    ///     cannot take the money twice.
    /// @return the processor's reference for the charge
    /// - `PaymentDeclinedException`: when the card was refused
    /// - `IOException`: when the processor could not be asked
    String charge(String key, String customer, String token, long amountCents, String currency,
            String description) throws IOException;

    /// Returns `amountCents` of a charge to the card it came from.
    ///
    /// A processor may take the refund and only later say whether the bank
    /// returned the money: such a refund comes back [Refund#pending], and is
    /// asked about with [#refundStatus] until it has an answer.
    ///
    /// @return the refund as the processor took it
    /// - `IOException`: when the processor could not be asked, or refused the
    ///   refund outright
    Refund refund(String chargeReference, long amountCents) throws IOException;

    /// What became of a refund that was [Refund#pending]: [#REFUND_SUCCEEDED],
    /// [#REFUND_PENDING] while the processor still does not know, or
    /// [#REFUND_FAILED] when the money did not go back.
    String refundStatus(String refundReference) throws IOException;
    /// Holds `amountCents` on a saved card without taking it, for an order
    /// that is paid for only once it arrives. Keyed as [#charge] is.
    ///
    /// @return the processor's reference for the hold, for [#capture] or [#release]
    /// - `PaymentDeclinedException`: when the card was refused
    String authorize(String key, String customer, String token, long amountCents,
            String currency, String description) throws IOException;
    /// Takes `amountCents`, at most what was held, from a hold. A hold that was
    /// already captured is captured: this is asked again when the first answer
    /// was lost.
    void capture(String reference, long amountCents) throws IOException;
    /// Lets a hold go, so the money is the cardholder's again. A hold that is
    /// already let go is released.
    void release(String reference) throws IOException;

The owner’s day

@Transactional(readOnly = true)
public DashboardDto read() {
    Business business = catalog.main();
    ZoneId zone = catalog.zone();
    LocalDate today = LocalDate.now(zone);
    LocalDate first = today.minusDays(DAYS - 1);
    long from = Availability.at(first, 0, zone);
    long to = Availability.at(today.plusDays(1), 0, zone);
    List<Appointment> all = appointments.started(Instant.ofEpochMilli(from),
            Instant.ofEpochMilli(to));
    int booked = 0;
    int completed = 0;
    int noShows = 0;
    int cancelled = 0;
    long revenue = 0;
    long bookedMinutes = 0;
    int[] byDay = new int[DAYS];
    Map<String, Integer> byService = new HashMap<String, Integer>();
    Map<String, Integer> byProvider = new HashMap<String, Integer>();
    List<String> ids = new ArrayList<String>();
    for (int iter = 0; iter < all.size(); iter++) {
        Appointment a = all.get(iter);
        String state = a.getState();
        if (States.HELD.equals(state)) {
            continue;
        }
        if (States.CANCELLED.equals(state)) {
            cancelled++;
        } else {
            booked++;
            ids.add(a.getId());
            bump(byService, a.getServiceId());
            int day = (int) (Instant.ofEpochMilli(a.getStartsAt().toEpochMilli()).atZone(zone)
                    .toLocalDate().toEpochDay() - first.toEpochDay());
            if (day >= 0 && day < DAYS) {
                byDay[day]++;
            }
            bookedMinutes += (a.getBlockEnd().toEpochMilli()
                    - a.getBlockStart().toEpochMilli()) / 60000L;
        }
        if (States.COMPLETED.equals(state)) {
            completed++;
        } else if (States.NO_SHOW.equals(state)) {
            noShows++;
        }
        if (States.PAY_CAPTURED.equals(a.getPaymentState())) {
            revenue += a.getDepositCents();
        }
        if (States.COMPLETED.equals(state)) {
            // The rest of the price is paid on the day.
            revenue += a.getPriceCents() - (States.PAY_CAPTURED.equals(a.getPaymentState())
                    ? a.getDepositCents() : 0);
        }
    }
    if (!ids.isEmpty()) {
        List<AppointmentParty> who = parties.findByAppointmentIdIn(ids);
        for (int iter = 0; iter < who.size(); iter++) {
            if (States.PROVIDER.equals(who.get(iter).getKind())) {
                bump(byProvider, who.get(iter).getRef());
            }
        }
    }
    List<Integer> days = new ArrayList<Integer>();
    for (int iter = 0; iter < DAYS; iter++) {
        days.add(Integer.valueOf(byDay[iter]));
    }
    return new DashboardDto(booked, completed, noShows, cancelled, revenue,
            business.getCurrency(), utilisation(bookedMinutes), days, top(byService, true),
            top(byProvider, false));
}
/// The owner's home: how the last weeks went -- booked, done, no-shows,
/// cancelled, takings and how full the providers' hours were -- and the way
/// into the day board, the catalogue and the booking rules.
public class OwnerForm extends LiveChannel.Adapter implements Nav.Home {
    private final Form form = Ui.home("Owner");
    private final Menu menu = new Menu(form, Nav.OWNER);
    private final Container page = Ui.page();

    @Override
    public Form form() {
        return form;
    }

    @Override
    public void start() {
        menu.item(FontImage.MATERIAL_DASHBOARD, "Dashboard", "dashboard", () -> form.showBack());
        menu.item(FontImage.MATERIAL_VIEW_WEEK, "Day board", "board", () -> BoardForm.show(form));
        menu.item(FontImage.MATERIAL_SPA, "Services", "services",
                () -> CatalogForm.show(form, CatalogForm.SERVICES));
        menu.item(FontImage.MATERIAL_PEOPLE, "Providers", "providers",
                () -> CatalogForm.show(form, CatalogForm.PROVIDERS));
        menu.item(FontImage.MATERIAL_PLACE, "Locations", "locations",
                () -> CatalogForm.show(form, CatalogForm.LOCATIONS));
        menu.item(FontImage.MATERIAL_CHAIR, "Rooms and equipment", "resources",
                () -> CatalogForm.show(form, CatalogForm.RESOURCES));
        menu.item(FontImage.MATERIAL_RULE, "Booking rules", "policy", () -> PolicyForm.show(form));

        Container bar = new Container(new BorderLayout());
        bar.setUIID("SvHomeBar");
        bar.add(BorderLayout.WEST, menu.flatButton());
        bar.add(BorderLayout.CENTER, Ui.label("Dashboard", "SvTitle"));
        bar.add(BorderLayout.EAST, Ui.flat(FontImage.MATERIAL_SETTINGS, "Settings", "home-settings",
                e -> SettingsForm.show(form)));
        page.setName("dashboard");
        Ui.pull(page, this::load);
        Container shell = new Container(new BorderLayout());
        shell.add(BorderLayout.NORTH, bar);
        shell.add(BorderLayout.CENTER, page);
        form.add(shell);
        menu.install();
        form.addShowListener(e -> {
            LiveChannel.listen(this);
            load();
        });
    }

    @Override
    public void appointmentChanged(String id, String state) {
        load();
    }

    private void load() {
        Api.owner().dashboard(Net.to(this::show, Ui::fail));
    }

    private void show(DashboardDto d) {
        if (!Nav.isHome(form)) {
            return;
        }
        page.removeAll();
        StatRow top = Ui.stats(3);
        top.stat(String.valueOf(d.booked()), "Booked", "stat-booked");
        top.stat(String.valueOf(d.completed()), "Done", "stat-completed");
        top.stat(Ui.moneyShort(d.revenueCents(), d.currency()), "Taken", "stat-revenue");
        page.add(top);
        StatRow second = Ui.stats(3);
        second.stat(d.utilisation() + "%", "Hours filled", "stat-utilisation");
        int shown = d.completed() + d.noShows();
        second.stat(shown == 0 ? "0%" : (d.noShows() * 100 / shown) + "%", "No-show rate",
                "stat-no-shows");
        second.stat(String.valueOf(d.cancelled()), "Cancelled", "stat-cancelled");
        page.add(second);
        Ui.section(page, "Bookings a day");
        double[] days = new double[d.bookedByDay().size()];
        for (int iter = 0; iter < days.length; iter++) {
            days[iter] = d.bookedByDay().get(iter).intValue();
        }
        page.add(Chart.of("Sv", "chart-booked", days, Lang.tr("{0} days ago",
                String.valueOf(days.length - 1)), Lang.tr("Today")));
        String trend = trend(days);
        if (trend != null) {
            Label change = Ui.label(trend, "SvCaption");
            change.setName("booked-trend");
            page.add(change);
        }
        counts(page, "Most booked", d.topServices(), FontImage.MATERIAL_SPA);
        counts(page, "Busiest providers", d.providerLoad(), FontImage.MATERIAL_PERSON);
        page.add(Ui.primary("Open the day board", "open-board", e -> BoardForm.show(form)));
        Layouts.broaden(page);
        Ui.relayout(page);
    }

    /// The second half of the period against the first: is the business
    /// growing. Null while the first half has nothing to compare with.
    static String trend(double[] days) {
        int half = days.length / 2;
        double before = 0;
        double after = 0;
        for (int iter = 0; iter < half * 2; iter++) {
            if (iter < half) {
                before += days[days.length - half * 2 + iter];
            } else {
                after += days[days.length - half * 2 + iter];
            }
        }
        if (before <= 0) {
            return null;
        }
        long change = Math.round((after - before) * 100 / before);
        return Lang.tr("{0}% against the {1} days before", (change > 0 ? "+" : "") + change,
                String.valueOf(half));
    }

    /// Each row with a bar as long as its share of the busiest, so the
    /// ranking reads at a glance.
    private static void counts(Container page, String title, List<NameCountDto> rows, char icon) {
        if (rows.isEmpty()) {
            return;
        }
        Ui.section(page, title);
        Container list = new Container(BoxLayout.y());
        int most = 1;
        for (int iter = 0; iter < rows.size(); iter++) {
            most = Math.max(most, rows.get(iter).count());
        }
        for (int iter = 0; iter < rows.size(); iter++) {
            NameCountDto r = rows.get(iter);
            list.add(Ui.row(icon, r.name(), "", Ui.label(String.valueOf(r.count()), "SvValue")));
            list.add(new Chart.Meter("Sv", r.count() / (double) most));
        }
        page.add(list);
    }
}

Tests

cd scripts/slate
# The server's tests on the JVM, then compiled to a native binary
mvn -Dcodename1.platform=backend -pl shared,backend test -Dcn1.backend.compiledTests=true
# The same tests on PostgreSQL or MySQL
CN1_TEST_DATABASE_URL="postgres://user:password@localhost:5432/db?sslmode=disable" \
  ../ci/sample-server-tests-on-database.sh .
# The app against a running server, in the simulator
./run-e2e.sh
./run-e2e.sh --native
# The app in a headless browser, served by the server
./run-web-e2e.sh

The slot engine’s rules are tested one at a time, against the engine alone, and again end to end through the booking service. run-e2e.sh starts the server and drives the app in the simulator as a client, a provider and an owner. sample-server-tests-on-database.sh runs the server tests against PostgreSQL or MySQL. CI runs all them, in .github/workflows/slate.yml.

Making it yours

  • Your business. Replace DemoBusiness with your locations, services, people and resources, or create them from the owner’s screens on an empty database:

    /// The business a server starts with.
    ///
    /// Everywhere, the business row itself, since nothing can be booked without
    /// one: named, in `slate.timezone`, with the rules a small business would
    /// start from. On a development profile also everything a demo needs -- two
    /// locations twenty minutes apart, a handful of services of the vertical
    /// `slate.demo.vertical` names (`SALON`, `TUTOR`, `TRAINER`, `CONSULTANT` or
    /// `CLINIC`), three providers with a working week, the rooms or chairs, four
    /// weeks of history and a few bookings to come.
    ///
    /// The bookings to come are made through [Bookings] like any client's, so the
    /// demo starts with nothing the slot engine would not have allowed. The
    /// `test` profile gets the business and no bookings: each test books its own.
  • Your vertical. A row in Verticals and, for the demo, a set of services.

  • Your brand. Colors and shapes are in common/src/main/css/theme.css, text in common/src/main/l10n.

  • What it leaves out. Slate books single appointments. Packages and memberships, gift cards, reviews, recurring appointments, intake forms beyond the one question a service can ask, tips and staff commission aren’t included; each is a table and a screen on top of the booking rules, which stay as they are.

  • Your rules. Every gap rule is a column of the business that the owner edits and previews; the engine is where a new rule goes, with a reason to report.

Before it’s a product

The sample is complete as a demonstration. A product needs at least:

  • Recurring events in ICS calendars. A published calendar’s repeating events (RRULE) aren’t expanded; only single events count as busy. Google and Microsoft expand them on their side, so those are right.

  • Encrypted calendar credentials. Refresh tokens and ICS addresses are stored as they are. Encrypt them at rest, with a key outside the database, and let a provider revoke them.

  • Safe fetching of calendar addresses. The server fetches whatever ICS address a provider types. Restrict it to public https addresses, refuse private and loopback networks, and cap the size and time of each fetch.

  • Stricter concurrency on the daily rules. Cells make a double booking impossible, but a provider’s daily cap, their breaks and travel time, and a client’s bookings per day are checked by the engine before the claim. Two bookings placed in the same instant for different times can both pass. The hold narrows the window; a product that must never exceed them takes a lock per provider and client day.

  • Tested late cancellation and no-show fees. Taking a deposit is simulated and hasn’t been tested against a real processor’s capture and refund flows.

  • A real payment processor and PCI scope. Wire PaymentProvider to your processor and test it with its test keys. Keep card entry on the processor’s hosted page, confirm your PCI DSS self-assessment, and add receipts, tax, refunds and disputes.

  • Reviewed translations. The non-English bundles are machine translations; have them reviewed before release.

  • Operations. Monitoring, backups that have been restored in a test, a support process, and a channel between server instances before you run more than one.