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 coverA 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#
A gallery's attendants#
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#
- The Qualifications kit — licences, certificates, inductions and signed waivers a person holds — what a person holds, which a position can require
- deny … except via — how a place changes hands only through the kit's functions
- Time zones — TimeZoneInfo — the rota's wall clock
- settings a package asks its app for (app settings) —
app.Rota - The Privacy kit — erasure that keeps only what the company must or may — a person's rota in their privacy requests
kits/shift-scheduling/tests/— every behaviour above as a test, the screens under--pixels