Shift states ¶
The state of a shift is calculated by examining the state of multiple values on the shift, and to be stored in a ‘status’ column.
Historically, the calculations were done on the fly, whether in php code, or sql, and in some cases, in client-side javascript. Centralizing the calculations and storing one canonical state per shift will reduce the potential for various consumers of shift data from being out of sync with one another, by reducing (or eliminating) the need for runtime calculations.
Also historically, the states of a shift were calculated to match a particular workflow. The indevet workflow will be changing, and as such, the calculations will need to change. but, we will need to keep track of which version of calculations needs to be used to (re)calculate the status of a shift.
For example: a new proposed workflow may consider a shift to be ‘no show’ if certain conditions are met; those same conditions may exist on historical shifts, but the concept of ‘no show’ may not be applicable to those shifts, because a ‘timeclock’ feature was not available for users of those shifts when they were active.
To accommodate this, a ‘shift_version’ flag will need to be added to the shift table, with all existing shifts being marked as 1, and newly created shifts marked as 2 (a precise cutover time will be determined in the future, possibly implemented via some published config options).
The current ‘status’ workflow and flags (what we’ll refer to as version 1) are determined in a few areas of code, but are likely best represented in this pseudocode.
version 1 status logic ¶
- is shift published?
- no
- is shift cancelled?
- yes
- status = CANCELLED
- no
- status = NEW
- yes
- is shift cancelled?
- yes
- status = CANCELLED
- no
- status = OPEN
- is employee id set?
- yes
- status = IN REVIEW
- is shift ‘notified’ flag set?
- status = FILLED
new proposed status logic
version 2 status logic ¶
version 2 is slightly more complicated, and is outlined in a google doc on page 2 https://docs.google.com/document/d/1DZ-b4ospDV2fbjHdmCoa_KUwATAPL4bXBjTRt96F3y0/edit
First pass of code is in ShiftService
public function calculateStatusVersion2(Shift $shift, Carbon $when = null): string
{
$when = $when ?? Carbon::now();
if ($shift->isCanceled()) {
$status = Shift::STATUS_CANCELED;
} else {
if (!$shift->isPublished()) {
$status = Shift::STATUS_NEW;
} else {
if (null === $shift->employee_id || 0 === $shift->employee_id) {
$status = Shift::STATUS_OPEN;
} else {
if (!$shift->isApproved()) {
$status = Shift::STATUS_ASSIGNED;
} else {
if (!self::isClockedIn($shift)) {
$endTime = $this->convertWallTimeToTimeZone($shift->end_time, $shift->timezone);
if ($when->isAfter($endTime)) {
$status = Shift::STATUS_NO_SHOW;
} else {
$status = Shift::STATUS_FILLED;
}
} else {
if (!self::isClockedOut($shift)) {
$status = Shift::STATUS_IN_PROGRESS;
} else {
$status = Shift::STATUS_COMPLETED;
}
}
}
}
}
}
return $status;
}
This version is not finalized against the google doc (as of 4/15)
storing state ¶
To avoid runtime calculations, an actual ‘state’ value should be stored and associated with a shift. The state can be interpreted by various actors to provide different functionality, but the base idea of ‘state’ would be the same, calculated based on the above rules. Where the end result lives (and potentially where the calculations live) present us with a couple of options.
extra shift table column ¶
We can add a new simple ‘status’ column in the ‘shift’ table itself, updated in code via ‘calculate’ methods. The calculate methods could be wired up to happen before every save/update event, to reduce the need for explicitness, but would be reliant on a degree of hiddenness. Having more tests in place around both explicit shift state changes and other operations which can affect state indirectly would help reduce the potential for missing explicit updates.
in a separate database view ¶
Database views can provide multiple views of the shift state. One view could embody multiple rules, or multiple views could exist which implement individual sets of rules.
Examples
drop view if exists shift_status_v1;
create view shift_status_v1 as
select id,
case
when (manual_status is not null) then manual_status
when (canceled_at is not null) then 'cancelled'
when ((EXTRACT(EPOCH FROM now()) - EXTRACT(EPOCH FROM shifts.created_at)) / 60) < 5 then 'new - pending'
when (published is false) then 'new'
when (employee_id is null or (employee_id = 0)) then 'open'
when ((EXTRACT(EPOCH FROM now()) - EXTRACT(EPOCH FROM shifts.assigned_at)) / 60) < 5 then 'assigned - pending'
when (is_approved is not true or is_approved is null) then 'assigned'
when (is_approved is true) then 'filled'
end as status
from shifts;
drop view if exists shift_status_v2;
create view shift_status_v2 as
select id,
case
when (canceled_at is not null) then 'cancelled'
when ((EXTRACT(EPOCH FROM now()) - EXTRACT(EPOCH FROM shifts.created_at)) / 60) < 5 then 'new - pending'
when (published is false) then 'new'
when (employee_id is null or (employee_id = 0)) then 'open'
when ((EXTRACT(EPOCH FROM now()) - EXTRACT(EPOCH FROM shifts.assigned_at)) / 60) < 5 then 'assigned - pending'
when (is_approved is not true or is_approved is null) then 'assigned'
when (clock_status is null
and (now() > shifts.end_time)
) then 'no show'
when (clock_status is null
and (now() < shifts.end_time)
) then 'filled'
when (clock_status is not null and clock_status != 'out') then 'in progress'
when (clock_status is not null and clock_status = 'out') then 'completed'
end as status
from shifts;
Upsides ¶
The ‘status’ can implement ‘real time’ calculations, to provide “pending” state as an actual state which is always ‘live’. “New - pending” and “assigned - pending” could be real time results requiring no extra client side processing, but this in itself may not actually be a benefit.
Downsides ¶
This approach requires always joining ‘shifts’ table against one or more views to get the ‘status’ in one record, and the ‘status’ may not be directly accessed as ‘status’.
Speed/impact - the impact of views as the shifts grow might have potential speed impact. We should run a test of, say, 100-150k shifts to determine what impact this may have.
Having state in a separate table may present more problems than anticipated.
flags (“pending”) ¶
Some operations need to determine if a state is within a certain time period. The ‘new’ state, when viewed within 5 minutes of creation, is considered to be “pending”, in that the full impact of ‘new’ may not be available yet to some roles in some screens. Similar behaviour may impact “assigned” state - a shift “assigned” within a few minutes may be “assigned - pending”.
Calculating these at run time as part of “state” means that for some operations, we’d be having to check for “new” or “new - pending”, when, in many cases, the overarching “new” is what is cared about.
This functionality may support the idea of ‘views’ as a standard way of calculating status; however, having these statuses represented with “pending” string in them creates some confusing or useless data states. Exporting an “all shifts report” with “assigned” and “assigned - pending” as separate states requires more thought or processing for the client - the “pending” aspect is out of date minutes after download. The “pending” concept is a functional modifier on the ‘state’, not truly the state itself (or not truly enough to warrant storing it as such).
As such, proof-of-concept methods for determining if a shift is in a ‘pending’ state were added to the shiftService
isNewPending()
isAssignedPending()
Currently, these only apply when called passing in a single shift. Helper methods to iterate through a collection and add boolean ‘isNewPending’ and ‘isAssignedPending’ flags could be added to facilitate collection processing before sending down to browser/clients for client-side processing. The Laravel model ‘appends’ processing hook may be another option to investigate for runtime pending/flag calculations.
migrating/testing ¶
Some POC for these ideas exists in ShiftService in a shift-work-2 branch. Some further work could be done to incorporate shift state calculation and display on either existing screens or as an additional report to allow for investigation/review/comparison against existing data.
update / progress / April 28 2020 ¶
version ¶
Based on some conversations with client, and modifications to agreed nomenclature, the ‘version’ aspect may not be required in the short term for a launch. Some existing tests were left in place to demonstrate the differences, but there will be only one ‘version’ of shift for the immediate future. When a future situation arises needing to allow for parallel state calculations, this will be accomodated.
Shift model ¶
Model has a ‘getStatusValue()’ method to get the actual attribute value.
There is an existing method - ‘status()’ in place, and while it may be doable to rename it and have this newer method just be called ‘status’, that was pushed back for a future task for two reasons.
- There are not existing tests to ensure that a change won’t break anything
- Accessing as just ‘status’ may trigger ‘getStatus’ or ‘status’ - there’s some extra magic method stuff that seemingly goes on sometimes, and it’s easier to avoid that potential minefield right now.
calculating state ¶
The shift state is calculated with a call to
$status = $shiftService->calculateStatus($shift);
$status is returned. To for a save on the shift directly,
$status = $shiftService->calculateAndSaveStatus($shift);
$status is returned, but also saved.
changing state ¶
The shiftService class has multiple methods to help migrate between states of a shift
publish a shift ¶
$status = $shiftService->publish($shift, $actor, true);
This will * publish the shift * record the actor/user who published in shift_activity * save the record * publish the record to whenIwork
assign a shift ¶
$status = $shiftService->assign($shift, $employee, $actor);
This will * assign the shift to the employee * record the actor/user who assigned in shift_activity * save the record * update the change to whenIwork
If the $actor is empty, the actor is assumed to be the user associated with the employee record. This will be considered a ‘self-assigned’ shift.
unassign a shift ¶
$status = $shiftService->unassign($shift, $employee, $actor);
This will * unassign the shift to the employee * record the actor/user who unassigned in shift_activity * save the record * update the change to whenIwork
approve a shift ¶
$status = $shiftService->approve($shift, $actor);
This will * approve the shift * record the actor/user who approved in shift_activity * save the record * update the change to whenIwork
POTENTIAL CONFLICT ¶
This shiftService state modifications and calculations are tested in Unit/ShiftStateTest (likely more can be added).
The vet portal calls Ajax/ShiftController which invokes the shiftService.
The existing front-end hospital manager and IV admin screens do not invoke the shiftService for state calculations and changes.
This is likely to create a potential conflict.
Conversion of hospital manager and IV admin controllers should be done to ensure consistency (with appropriate automated tests).
“STATUS” is lazy-updated for shifts that during an AJAX call for a shift list during vet portal shift shopping requests.
This is intended as a temporary measure to ensure that a status is available for the shift shopping process/views, but without interrupting the rest of the existing code/process right now.
TO AID IN VERIFYING STATUS ¶
the IV admin screens have been updated to show an “STATUS” column this was done in one commit to help identify the screens affected to later remove the column if necessary
the calculated status could be added to ‘all shifts report’ as well, but was not yet done.
POTENTIAL AID IN INITIAL CUTOVER ¶
docker-compose exec app artisan shifts:status convert
This is a console command to load/calculate/save status for each shift. It currently takes around 15 minutes to process ~18k shifts, and is intended as a one time utility. However, it may not actually be necessary to run until the rest of the UI screens handle status calculations.
Testing ¶
After basic seeding via
docker-compose exec app artisan migrate:fresh --seed