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

> `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.

<!-- id: ui-shift-scheduling-kit · area: ui · stability: preview · html: https://osysharp.com/reference/ui/shift-scheduling-kit/ -->

## Summary        {#summary}
`use Osysharp.ShiftScheduling@0;` gives an app a staff rota. The app says who runs it and who works it:

```osy syntax
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      {#signature}
```osy syntax
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    {#description}

### What is a shift?   {#shifts}
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?   {#eligibility}
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 `IShiftRule`s. 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?   {#requirements}
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](https://osysharp.com/reference/ui/qualifications-kit/) 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?   {#time-off}
`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?   {#cover}
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   {#hours}
`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?   {#pages}
`/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?   {#privacy}
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](https://osysharp.com/reference/project/bridges/)) 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       {#examples}

### A gallery's attendants   {#example-gallery}
```osy title="gallery attendants: the rota, a rule of the gallery's own, and the reminder sent as a message" test app=ui-shift-scheduling-kit
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       {#see-also}
- [The Qualifications kit — licences, certificates, inductions and signed waivers a person holds](https://osysharp.com/reference/ui/qualifications-kit/) — what a person holds, which a position can require
- [deny … except via](https://osysharp.com/reference/security/except-via/) — how a place changes hands only through the kit's functions
- [Time zones — TimeZoneInfo](https://osysharp.com/reference/stdlib/zones/) — the rota's wall clock
- [settings a package asks its app for (app settings)](https://osysharp.com/reference/config/app-settings/) — `app.Rota`
- [The Privacy kit — erasure that keeps only what the company must or may](https://osysharp.com/reference/security/privacy-kit/) — a person's rota in their privacy requests
- **`kits/shift-scheduling/tests/`** — every behaviour above as a test, the screens under `--pixels`
