The admin side of scheduling: booking states, a day-grouped calendar and honest no-show rates

Booking pages get the attention, but most of the work in a scheduling product happens after the booking: approving it, checking people in, seeing the week at a glance, and knowing what actually happened. This is how the admin side works in VeloxonHub. The 46-second reel shows it from the outside; here's the inside. 1. One table for two kinds of booking Appointments (1:1 calls) and events (workshops, Q&As) are both "scheduling products", and both write to the same registrations_bookings table. A row has a status and a separate payment_status: status: pending, confirmed, waitlisted, attended, no_show, cancelled, rescheduled, refunded payment: free, unpaid, paid, refunded, credit_used… Keeping the two apart matters. A waitlisted registration can be unpaid, a confirmed call can be free, and a refund doesn't mean the booking never happened. The admin list shows both columns, and the actions on each row come from the status: pending → Approve (for products that require ap

The admin side of scheduling: booking states, a day-grouped calendar and honest no-show rates

Booking pages get the attention, but most of the work in a scheduling product happens after the booking: approving it, checking people in, seeing the week at a glance, and knowing what actually happened. This is how the admin side works in VeloxonHub. The 46-second reel shows it from the outside; here's the inside.

1. One table for two kinds of booking

Appointments (1:1 calls) and events (workshops, Q&As) are both "scheduling products", and both write to the same registrations_bookings table. A row has a status and a separate payment_status:

  • status: pending, confirmed, waitlisted, attended, no_show, cancelled, rescheduled, refunded
  • payment: free, unpaid, paid, refunded, credit_used…

Keeping the two apart matters. A waitlisted registration can be unpaid, a confirmed call can be free, and a refund doesn't mean the booking never happened. The admin list shows both columns, and the actions on each row come from the status:

  • pending → Approve (for products that require approval before confirming)
  • confirmed → Check in
  • anything not cancelled, refunded or attended → Cancel
  • Join Now only appears inside the join window, using the same joining_details the client sees

Every action is a small POST (/scheduling/bookings/approve, /scheduling/bookings/check-in), and the list refetches, so the admin never edits status by hand.

2. The calendar is a list, grouped by day

The admin calendar loads occurrences (not bookings) for a date range, optionally filtered by type=event or type=appointment. Each occurrence carries its host, booked_count and capacity, so a row reads "Maya Rahman · 42 booked / 100".

On the client, grouping is a plain Map keyed by the localized date, so headings come out as "Friday, October 23" in the viewer's own time zone. The default range is today plus 30 days: for people running sessions, "what's happening today and tomorrow" matters more than a month grid.

3. Hosts own their availability

A host is a profile (display name, bio, expertise tags, time zone) plus weekly availability rules:

interface AvailabilityRule {
  host_id: number;
  weekday: number;   // 0 = Sunday
  start_time: string; // "09:00:00"
  end_time: string;
  timezone: string;
}

The editor turns rules into a week (rulesToWeek) and back (weekToRules). Each day is a list of windows, so "9–12 and 14–17" is two rules, not a special case. Rules can be global for the host or per product, and the booking page only generates slots inside them. The creator hosting themselves is marked is_self, which doesn't use up a host seat on their plan.

4. Hosts get their own dashboard, decided by permission

A teammate becomes a host when a scheduling_hosts row points at their user. The backend turns that ownership into a main.host_dashboard permission, and the frontend reads it:

export function isSchedulingHost(user) {
  return hasResourceAccess(user, "main.host_dashboard");
}
export function isSchedulingHostOnly(user) {
  return isSchedulingHost(user) && hasNoDashboardRole(user, ["main.host_dashboard"]);
}

A host with no other admin role is sent to /dashboard/products/scheduling/host-dashboard after login, and the navigation hides everything else. The page reads two self-scoped endpoints, /student/scheduling/host-dashboard (sessions, average rating, reviews, pending approvals, no-shows) and /student/scheduling/host-bookings (only bookings where they are the host), so the server, not the UI, decides what a host can see. The same availability editor opens in self-service mode and saves to the host's own rules.

5. Analytics: count no-shows only against people who could have shown up

All three analytics views (events, appointments, hosts) are SQL aggregations with COUNT(...) FILTER (WHERE ...). The detail we care most about is the denominator:

ROUND(
  COUNT(rb.id) FILTER (WHERE rb.status = 'no_show')::NUMERIC /
  NULLIF(COUNT(rb.id) FILTER (WHERE rb.status IN ('attended','no_show')), 0) * 100, 2
) AS no_show_rate

Attendance and no-show rates divide by attended + no-show only. A cancellation two days ahead isn't a no-show, and a booking for next week hasn't had the chance to be either. Dividing by all bookings would make every new product look like it has a no-show problem.

Event revenue is paid minus refunded (net_revenue), appointment revenue is paid bookings, and the host view adds sessions, attendees and utilization (non-cancelled bookings per session), so you can see which host is carrying the calendar.

What we'd tell someone building the same thing

  • Keep booking status and payment status as two fields.
  • Derive row actions from status; don't let admins type a status.
  • Give hosts rules, not slots. Generate slots from the rules.
  • Scope a host's view on the server, from who they are, not from a filter in the UI.
  • Pick your no-show denominator on purpose, and write it down.

Try it with a free trial: https://veloxonhub.com

Share