Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

The ShiftScheduling kit — a staff rota: shifts, places, time off, cover and hours

use Osysharp.ShiftScheduling@0; · policy ManagesShifts => …; policy WorksShifts => …; · AddShift(title, starts, ends, needed) · PublishShifts(from, to) · AssignShift(shift, worker) · ClaimShift(shift) · WhyNotOnShift(shift, worker) · RequestCover(place) · RequestSwap(place, theirs) · RequestTimeOff(from, to) · HoursBetween(from, to) · CopyWeek(from, to)

`Osysharp.ShiftScheduling` is a staff rota: shifts with a number of numbered places, built as drafts and published in one go, filled by a manager or claimed by the people who work them — never over-filled, one place a person, no overlaps, approved time off and weekly unavailability respected, an hours cap, a position's requirements and the app's own rules asked. Cover requests and swaps with approval, a week copied, hours per person, and a reminder before every shift.

preview1 example compiled by CIuikitrotascheduling

Source on GitHub: Osysharp.Privacy.ShiftScheduling — github.com/osysharp/privacy/shiftscheduling ↗ · Osysharp.ShiftScheduling — github.com/osysharp/shiftscheduling ↗

Summary#

use Osysharp.ShiftScheduling@0; gives an app a staff rota. The app says who runs it and who works it:

policy ManagesShifts => IsManager;   // builds and publishes the week, puts anybody on, approves time off and cover
policy WorksShifts => IsStaff;     // sees the published rota, claims open places, asks for time off and cover

A manager adds shifts as drafts, puts people on them (AssignShift) and publishes the week (PublishShifts); people claim open places themselves (ClaimShift). Every way onto a shift asks the same check and answers a sentence when the answer is no.

Signature#

virtual policy ManagesShifts => false;     virtual policy WorksShifts => false;
app.ShiftScheduling = new ShiftSchedulingSetup { TimeZone = "Europe/London", ReminderLead = TimeSpan.FromHours(24), WorkersClaimOpenShifts = true,
                           WorkersMayDrop = false, DropCutoff = TimeSpan.FromHours(48), CoverNeedsApproval = true,
                           WorkersRequestTimeOff = true, TimeOffNoticeDays = 0, MaxHoursPerWeek = 0m };

public interface IShiftRule { string? Refusal(Shift shift, Principal worker); }                       // the app's own rules
public interface IShiftRequirements { string? Unmet(Principal worker, string requirement, DateTime on); }   // a position's needs

Shift AddShift(string title, DateTime startsAt, DateTime endsAt, int needed = 1, ShiftPosition? position = null, …);
void UpdateShift(Shift shift, DateTime startsAt, DateTime endsAt, int needed);
int PublishShifts(DateTime from, DateTime to);      void CancelShift(Shift shift, string reason = "");
int CopyWeek(DateOnly fromMonday, DateOnly toMonday);   void DeleteDraftShift(Shift shift);
ShiftOutcome AssignShift(Shift shift, Principal worker, bool despiteWarnings = false);
ShiftOutcome ClaimShift(Shift shift);      void Unassign(ShiftAssignment a);      void DropShift(ShiftAssignment a);
string WhyNotOnShift(Shift shift, Principal worker);
ShiftCoverRequest RequestCover(ShiftAssignment mine, string reason = "", Principal? to = null);
ShiftCoverRequest RequestSwap(ShiftAssignment mine, ShiftAssignment theirs, string reason = "");
ShiftCoverRequest AcceptCover(ShiftCoverRequest r);     void DecideCover(ShiftCoverRequest r, bool approve, string note = "");
TimeOffRequest RequestTimeOff(DateOnly from, DateOnly to, string reason = "");
List<ShiftAssignment> DecideTimeOff(TimeOffRequest r, bool approve, string note = "");
Unavailability MarkUnavailable(Principal worker, DayOfWeek day, TimeOnly from, TimeOnly until, string note = "");
List<WorkerHours> HoursBetween(DateTime from, DateTime to);      decimal WeekHoursOf(Principal worker, DateOnly day);

Description#

What is a shift?#

A title, a start and an end (moments in UTC), how many people it Needed, an unpaid BreakMinutes, a place, a note, and optionally a ShiftPosition — the kind of work ("Front desk", "Kitchen"). It is a draft until published: only managers see it and nobody is reminded of it. PublishShifts(from, to) puts every draft in a period on the rota at once and raises ShiftsPublished naming everybody on them, to be told once. Its times and headcount change only through UpdateShift, which refuses a move that would give somebody a clash or a headcount below a place already taken, and tells the people on it (ShiftChanged). A published shift is cancelled, never deleted: CancelShift keeps who was on it and raises ShiftCancelled.

Who may be put on a shift?#

Every way onto a shift — a manager's assignment, a claim, cover, a swap — asks WhyNotOnShift, which answers "" or the reason: cancelled, already started, already on it, full, on another shift then, off that day (approved time off), a position's requirement unmet, or one of the app's IShiftRules. Two more are warnings rather than refusals: a weekly unavailability the person marked, and the week's MaxHoursPerWeek. A claim is refused for them; a manager's AssignShift answers NeedsConfirmation with the sentence and puts them on when asked again with despiteWarnings: true. A place is numbered (Slot), and both "one person per place" and "one place per person a shift" are declared [Unique] on ShiftAssignment, so no write can break them.

How does a position say what it needs?#

A PositionRequirement names a key ("food-hygiene"). The app answers with an IShiftRequirements — found, never wired; with several, a requirement is met when any confirms it, and with none it is never met. Osysharp.ShiftScheduling.Qualifications answers from Osysharp.Qualifications by itself in an app using both: the key is a qualification kind's, and the qualification must hold on the day the shift starts.

How do time off and availability work?#

RequestTimeOff(from, to, reason) asks for whole days on the rota's clock (ShiftSchedulingSetup.TimeZone); a manager approves or declines with DecideTimeOff, which answers the shifts the person is already on in that time — the kit never takes anybody off a shift by itself. RecordTimeOff is a manager's own entry, approved at once. MarkUnavailable records an hour of the week someone is never free ("Tuesdays all day", "Fridays 15:00–23:00").

How do cover and swaps work?#

A person on a published shift asks for cover — from anybody who may work it, or one named colleague — or offers a swap for a colleague's own place. Whoever takes it is checked like anyone else (their own place in a swap neither clashes nor counts). With CoverNeedsApproval it then waits for a manager's DecideCover, which checks again; without, the place changes hands at once. A place whose reminder already went out is reminded again for whoever has it now. Where WorkersMayDrop is on, a person may give a place up no later than DropCutoff before it starts.

Hours and the reminder#

HoursBetween(from, to) adds up each person's shifts less breaks — for payroll, fairness and the cap; CopyWeek copies a week as drafts with nobody on them. Each place has its own durable reminder (ShiftReminder), ReminderLead before the shift: it sleeps while the shift is a draft, re-sets when the shift is published or moved, stops when the place is given up, and raises ShiftReminderDue — the app chooses how to tell the person.

Which pages does it serve?#

/shifts — the signed-in person's shifts (ask for cover, drop), the cover they could take, open shifts to claim, their time off and when they are not free. /staff/rota — the manager's week: seven days, each shift's fill, add and change, put people on with the warnings shown, publish, copy last week, everybody's hours. /staff/rota/requests — time off and cover waiting, and who is away. /staff/rota/positions — positions and what they require. Each stands in the app's working shell with theme tokens only; take one over by declaring a component of the same name, or place MyShifts, RotaWeek, RotaRequests, RotaPositions or ShiftCardView on a page of your own.

Whose data is kept, and how does it go?#

Why somebody asked for time off or cover is Personal.Content. With Osysharp.Privacy, Osysharp.Privacy.ShiftScheduling arrives by itself (Bridges — a package that arrives when an app uses the two it completes) and counts, copies and erases a person's places, time off, unavailability and cover requests; their places on shifts still to come free up again.

Examples#

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

using Osysharp.Identity;
using Osysharp.ShiftScheduling;

[Role] enum GalleryRole { Authenticator, Attendant, Supervisor }
policy IsSupervisor => RoleGrant.Any(g => g.Member == user && g.Role == GalleryRole.Supervisor);
policy IsAttendant => RoleGrant.Any(g => g.Member == user && g.Role == GalleryRole.Attendant);
policy ManagesShifts => IsSupervisor;
policy WorksShifts => IsAttendant;

app.ShiftScheduling = new ShiftSchedulingSetup { TimeZone = "Europe/Paris", ReminderLead = TimeSpan.FromHours(18), MaxHoursPerWeek = 35m };

/// The gallery's own rule: nobody works the late opening two weeks running.
public class NoTwoLateOpenings : IShiftRule {
  public string? Refusal(Shift shift, Principal worker) {
    if (shift.Title != "Late opening") { return null; }
    var since = shift.StartsAt.AddDays(-8);
    var start = shift.StartsAt;
    if (!ShiftAssignment.Any(a => a.Worker == worker && a.Shift.Title == "Late opening" && a.Shift.StartsAt > since && a.Shift.StartsAt < start)) { return null; }
    return "Nobody works the late opening two weeks running.";
  }
}

/// What the attendant reads the evening before.
entity RotaMessage {
  [Required("Who is it for?")] Principal To;
  [MaxLength(200)] string Text = "";
  security { allow read when IsAuthenticated; }
}

[On]
void RemindTheAttendant(ShiftReminderDue e) {
  new RotaMessage { To = e.Assignment.Worker, Text = "Tomorrow: " + e.Assignment.Shift.Title };
}

See also#

Related

The Qualifications kit — licences, certificates, inductions and signed waivers a person holds

`Osysharp.Qualifications` keeps what a person holds that lets them do something — a licence, a certificate, an…

deny … except via

Names the ONLY functions whose own code may change something a deny withholds: `deny update UnitsLeft except via…

[Event] — what happens to a record, declared once

An event is a class marked `[Event]`: something that happens to a record, declared once by the code that owns the…

[On] — hear an event, after the commit that raised it

`[On]` on a function makes it a HANDLER of the event its one parameter is. Every handler of an event — in the app and…

settings a package asks its app for (app settings)

A package — or an app — declares a setting by marking a class `[AppSetting("Name")]`. The app that uses it fills it in…

Time zones — TimeZoneInfo

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

The Privacy kit — erasure that keeps only what the company must or may

`Osysharp.Privacy` answers a person's GDPR request — access, portability, erasure, restriction — per category of what…

Bridges — a package that arrives when an app uses the two it completes

A bridge is a package whose manifest says `completes A@0, B@0;`. An app that uses every package it completes gets it…