Osy#betaRun it on Osyrin: osyrin.com

Reference / Scheduling

The ClassBookings kit — classes, courses and lessons with places, a waiting list and a register

use Osysharp.ClassBookings@0; · entity YogaClass : BookableSession { … } · BookSession(session, person) · CancelBooking(booking) · CheckIn(booking) · PlanSeries(series, through)

`Osysharp.ClassBookings` is bookable sessions with a fixed number of numbered places that are never overbooked: a first-come waiting list whose freed places are offered for a limited time (or given at once) on a durable clock, booking windows, late cancellations and no-shows heard as events, check-in, walk-ins and a register that closes by itself, reminders, weekly series in the app's time zone with one-off changes, instructors, rooms and equipment that no two sessions hold at once, app-defined booking rules, and booking for the people one acts for — with a timetable, a my-bookings page and the register.

preview1 example compiled by CIschedulingkitbookings

Source on GitHub: Osysharp.ClassBookings — github.com/osysharp/classbookings ↗

Summary#

use Osysharp.ClassBookings@0; gives an app sessions people book places in. The app derives its own kind of session, says who runs them, and — if people book for others — who acts for whom:

entity CookingClass : BookableSession { … security { allow read when IsAuthenticated; } }
policy ManagesSessions => IsDesk;
policy TakesAttendance => IsDesk;                    // a session's own instructor takes its register anyway
policy BooksFor(Principal person) => person == user || ActsForDependant(person);

Signature#

virtual policy ManagesSessions => false;   virtual policy TakesAttendance => ManagesSessions;
virtual policy BooksSessions => IsAuthenticated;   virtual policy BooksFor(Principal person) => person == user;
virtual policy VisitorsSeePlacesLeft => false;   // may somebody NOT signed in see how full a session is
public policy TeachesSession(BookableSession session);
app.ClassBookings = new ClassBookingsSetup {
  OfferWindow = TimeSpan.FromHours(2), Waitlist = WaitlistMode.Offer,          // or Promote
  OpensBefore = TimeSpan.Zero, ClosesBefore = TimeSpan.Zero, CancelFreeUntil = TimeSpan.FromHours(12),
  RemindBefore = TimeSpan.FromHours(24), CloseRegisterAfter = TimeSpan.FromMinutes(30),
  SelfCheckIn = false, CheckInOpensBefore = TimeSpan.FromMinutes(30),
  People = new SessionPeople(), Maker = new SessionMaker(),
};
public interface IBookingRule { string? Refusal(BookableSession session, Principal person); }

BookingOutcome BookSession(BookableSession s, Principal person);   BookingOutcome CancelBooking(Booking b, bool waive = false);
BookingOutcome AcceptOffer(Booking b);   BookingOutcome DeclineOffer(Booking b);
BookingOutcome CheckIn(Booking b);   void UndoCheckIn(Booking b);   void MarkNoShow(Booking b);
BookingOutcome WalkIn(BookableSession s, Principal person);   int CloseRegister(BookableSession s);
void SetCapacity(BookableSession s, int capacity);   int CancelSession(BookableSession s);
void MoveSession(BookableSession s, DateTime startsAt, DateTime? endsAt = null);
SessionAssignment AssignResource(BookableSession s, SessionResource r);   void ReleaseResource(BookableSession s, SessionResource r);
SeriesPlan PlanSeries(SessionSeries series, DateOnly through);   List<BookingOutcome> BookSeries(SessionSeries series, Principal person);
int EndSeries(SessionSeries series, DateOnly from);   int ForgetBookingsOf(Principal person);
int PlacesLeft(BookableSession s);   int WaitingCount(BookableSession s);   int? QueuePosition(Booking b);
List<SessionLine> Timetable(DateTime from, int days, Principal person);   List<BookingLine> MyBookings(bool withPast = false);
// events: BookingConfirmed · JoinedWaitlist · PlaceOffered · OfferLapsed · BookingCancelled · LateCancellation ·
//         SessionCancelled · SessionMoved · PlacesChanged · SessionReminder · CheckedIn · NoShow
// pages: /timetable · /my-bookings · /register

Description#

Never overbooked — the data says so#

A held place is a numbered Seat, unique per session and never above its capacity, and a booking's state, seat and times change only through the kit's functions. Two people booking the last place at once cannot both have it; the loser is put on the waiting list or told why not. A session's capacity changes through SetCapacity, which never strands a held place, gives added places to the people waiting, and is heard as PlacesChanged (with how many places there were) — for whatever the app keeps or shows about the session itself.

The waiting list#

A full session puts a person at the back of its list (or refuses, when the session has none). A freed place goes to the first person in line whom the app's rules still allow: offered and held for OfferWindow — never past the session's booking close — then passed on, or, with WaitlistMode.Promote, booked for them at once. Each offer has its own durable timer, so an answered offer is never undone by an old one. Places pass on through the session's clock, which runs as the system, so a member who cancels never needs to see who is waiting.

Windows, late cancellation and no-shows#

Booking opens OpensBefore the start and closes ClosesBefore it. Giving up a confirmed place inside CancelFreeUntil is a late cancellation: the place is still freed, the booking is marked, and the app hears LateCancellation — to take a fee or a credit with its own payments. The desk may waive it. After the start only the desk cancels.

The register#

Whoever TakesAttendance, and a session's own instructor, check people in, walk them in (a free place booked and checked in at once, past the booking window and rules, never past the places) and mark them absent once the session has started. Each session's clock reminds the people booked RemindBefore the start (SessionReminder) and closes the register CloseRegisterAfter its end, marking anyone booked and not checked in absent (NoShow). A moved session re-arms both.

What does a session hold: instructors, rooms, equipment?#

A SessionResource is something a session holds — an instructor (with their account), a room, a lane, a piece of equipment, with the most people it takes. AssignResource refuses a resource another session holds at an overlapping time, and a room smaller than the session; MoveSession re-checks every resource at the new time.

How do series and courses work?#

A SessionSeries is a weekly session at a local time in a time zone — 18:00 stays 18:00 through daylight saving. PlanSeries makes the sessions not made yet, as the app's own kind (SessionMaker), holding the series' lead and place; a moved or cancelled meeting stays as it is. BookSeries books a whole course; EndSeries cancels it from a day on.

Who sees whom#

Everyone signed in sees places and counts; who holds a place is seen by the person, whoever booked or acts for them, the session's instructor and whoever takes registers.

Somebody who has not signed in sees no counts until the app says so. Showing them the session is the app's own grant on its session type; how full it is, is a second decision:

entity YogaClass : BookableSession { security { allow read when IsAuthenticated || IsAnonymous; } }   // the class itself
policy VisitorsSeePlacesLeft => true;                                                                // …and how full it is

With it, a visitor reads the rows of bookings with every column that says whose they are hidden, so PlacesLeft, WaitingCount and Timetable answer for them as for anyone. Without it they are refused — PlacesLeft throws NotAuthorized, naming that line — rather than told that every place is free. With Osysharp.Privacy, Osysharp.Privacy.ClassBookings arrives by itself and answers requests for a person's bookings.

Examples#

A yoga studio with a weekly class and a booking rule#

app YogaStudio {
  use Osysharp.Ui;
  use Osysharp.Workflow;
  use Osysharp.Identity@0;
  use Osysharp.ClassBookings@0;
  model "model/**/*.osy";
}

using Osysharp.Identity;
using Osysharp.ClassBookings;

[Role] enum StudioRole { Authenticator, Member, Teacher, Desk }
policy IsDesk => RoleGrant.Any(g => g.Member == user && g.Role == StudioRole.Desk);
policy ManagesSessions => IsDesk;
policy TakesAttendance => IsDesk;

app.ClassBookings = new ClassBookingsSetup { CancelFreeUntil = TimeSpan.FromHours(6), Waitlist = WaitlistMode.Promote };

/// The studio's own class: the kit's session with a level of its own.
entity YogaClass : BookableSession {
  [MaxLength(40)] string Level = "All levels";
  security { allow read when IsAuthenticated; allow create, update when ManagesSessions; }
}

/// Members only for the advanced class.
entity Membership {
  [Required] Principal Member;
  security { allow read when IsAuthenticated; }
}

class AdvancedNeedsMembership : IBookingRule {
  public string? Refusal(BookableSession session, Principal person) {
    var cls = YogaClass.FirstOrDefault(c => c.Id == session.Id);
    if (cls == null || cls.Level != "Advanced") { return null; }
    return Membership.Any(m => m.Member == person) ? null : "The advanced class is for members.";
  }
}

/// Every Tuesday at 07:00 in Stockholm for ten weeks.
SessionSeries MorningFlow() {
  return new SessionSeries {
    Title = "Morning flow", TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Stockholm"), Day = DayOfWeek.Tuesday, StartsAt = new TimeOnly(7, 0),
    Length = TimeSpan.FromMinutes(75), Capacity = 14, From = DateOnly.FromDateTime(DateTime.UtcNow),
    Until = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(70),
  };
}

See also#

Related

The Guardianship kit — who acts for whom: children, carers, consents and emergency contacts

`Osysharp.Guardianship` links a person to the people who look after them. A parent adds a child (an account the child…

The UserAccounts kit — sign-in done properly, once

`Osysharp.UserAccounts` is the default way into an app: sign-in with a password or a provider (Google, GitHub, …)…

Tracks and Initial (the field a workflow drives)

Names the enum field a workflow owns and the state a run starts in. No application code may write that field, and when…

Time zones — TimeZoneInfo

Store and use civil time zones with C#'s `TimeZoneInfo`. A zone holds an IANA id (`"Europe/Stockholm"`), and C#'s…