All files / server/src/features/assignment/utils getAssignmentBonus.ts

100% Statements 33/33
100% Branches 21/21
100% Functions 16/16
100% Lines 33/33

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153                                      52x             163x         469x       163x   254x           254x             2220x 2219x         52x                     399x 79x 80x           399x 50x       399x 527x   19176x   1043x                       527x     77x 77x     77x                     527x 37x   496x         399x   399x                 399x 490x   55x             399x   399x       399x    
import { partition, unique } from "remeda";
import { config } from "shared/config";
import { DIRECT_SIGNUP_PRIORITY } from "shared/constants/signups";
import { EventLogAction } from "shared/types/models/eventLog";
import { ProgramItem, State } from "shared/types/models/programItem";
import { User } from "shared/types/models/user";
import { isLotterySignupProgramItem } from "shared/utils/isLotterySignupProgramItem";
import { isSameTime } from "shared/utils/timeComparison";
import { getStartingProgramItems } from "server/features/assignment/utils/getStartingProgramItems";
import { DirectSignupsForProgramItem } from "server/features/direct-signup/directSignupTypes";
 
export interface AssignmentBonusContext {
  thisRunsProgramItemIds: ReadonlySet<string>;
  currentStartTimes: readonly string[];
  stillRunningProgramItemIds: ReadonlySet<string>;
}
 
// Built once per algorithm pass: every attendee group asks the same questions of the same
// programme
export const getAssignmentBonusContext = (
  allProgramItems: readonly ProgramItem[],
  assignmentTime: string,
): AssignmentBonusContext => {
  // The lottery program items this starting time decides. Not only the ones still being
  // lotteried: a retry drops the items an earlier attempt placed people into, and that
  // attempt's results must still read as this run's rather than as a lottery they lost.
  const startingProgramItems = getStartingProgramItems(
    allProgramItems,
    assignmentTime,
  ).filter(
    (programItem) =>
      isLotterySignupProgramItem(programItem) &&
      programItem.state === State.ACCEPTED,
  );
 
  return {
    thisRunsProgramItemIds: new Set(
      startingProgramItems.map((programItem) => programItem.programItemId),
    ),
    // A rejection names no program item, so it is matched by the time it carries: the run's own
    // hour, or any hour its program items start
    currentStartTimes: unique([
      assignmentTime,
      ...startingProgramItems.map((programItem) => programItem.startTime),
    ]),
    // A placement the attendee never got to attend is not one they spent: cancelling their own
    // sign-up costs the bonus, the program item being cancelled does not. Asked of the whole
    // programme, since a placement at any other start time is still a placement.
    stillRunningProgramItemIds: new Set(
      allProgramItems
        .filter((programItem) => programItem.state === State.ACCEPTED)
        .map((programItem) => programItem.programItemId),
    ),
  };
};
 
export const getAssignmentBonus = (
  attendeeGroup: User[],
  lotteryParticipantDirectSignups: readonly DirectSignupsForProgramItem[],
  {
    thisRunsProgramItemIds,
    currentStartTimes,
    stillRunningProgramItemIds,
  }: AssignmentBonusContext,
): number => {
  /** First time bonus */
 
  const isCurrentStartTime = (startTime: string): boolean =>
    currentStartTimes.some((currentStartTime) =>
      isSameTime(startTime, currentStartTime),
    );
 
  // This run wrote it: for one of the program items it decides, and at one of the hours it
  // covers. Neither half alone will do - an item can carry a placement from before it was
  // rescheduled onto this hour, and another lottery can cover this hour with its own items.
  const isThisRunsOwn = (programItemId: string, startTime: string): boolean =>
    thisRunsProgramItemIds.has(programItemId) && isCurrentStartTime(startTime);
 
  // Get group members with previous direct sign-ups or NEW_ASSIGNMENT event log items
  const [groupMembersWithDirectSignups, groupMembersWithoutDirectSignups] =
    partition(attendeeGroup, (groupMember) => {
      const previousDirectSignup = lotteryParticipantDirectSignups.find(
        (directSignup) => {
          return directSignup.userSignups.find(
            (userSignup) =>
              userSignup.username === groupMember.username &&
              // Exclude this run's own win (priority > 0), but keep first-come-first-served
              // (priority 0) sign-ups counting as "previous"
              !(
                isThisRunsOwn(
                  directSignup.programItemId,
                  userSignup.signedToStartTime,
                ) && userSignup.priority !== DIRECT_SIGNUP_PRIORITY
              ),
          );
        },
      );
      const newAssignmentEvent = groupMember.eventLogItems.find(
        (eventLogItem) => {
          const previousAssignment =
            eventLogItem.action === EventLogAction.NEW_ASSIGNMENT;
          const programItemExists = stillRunningProgramItemIds.has(
            eventLogItem.programItemId,
          );
          return (
            previousAssignment &&
            programItemExists &&
            !isThisRunsOwn(
              eventLogItem.programItemId,
              eventLogItem.programItemStartTime,
            )
          );
        },
      );
 
      if (previousDirectSignup ?? newAssignmentEvent) {
        return true;
      }
      return false;
    });
 
  // Give first time bonus to the whole group if half of the group members don't have previous direct sign-ups
  const averagePreviousDirectSignups =
    groupMembersWithDirectSignups.length / attendeeGroup.length;
  const firstTimeBonus =
    averagePreviousDirectSignups <= 0.5 ? config.server().firstSignupBonus : 0;
 
  /** Additional first time bonus */
 
  // Get group members with previous NO_ASSIGNMENT event log items and without direct sign-ups.
  // Ignore a NO_ASSIGNMENT from the current assignmentTime. A run that rejected everyone and
  // then failed before marking its items can be run again, and counting the items it wrote
  // would make the retry boost the very attendees the first attempt turned down.
  const groupMembersWithPreviousFailedLotterySignup =
    groupMembersWithoutDirectSignups.filter((groupMember) => {
      return groupMember.eventLogItems.find(
        (eventLogItem) =>
          eventLogItem.action === EventLogAction.NO_ASSIGNMENT &&
          !isCurrentStartTime(eventLogItem.programItemStartTime),
      );
    });
 
  // Give additional first time bonus to the whole group if half of the group members have previous failed lottery sign-ups
  const averageFailedLotterySignups =
    groupMembersWithPreviousFailedLotterySignup.length / attendeeGroup.length;
  const additionalFirstTimeBonus =
    averageFailedLotterySignups >= 0.5
      ? config.server().additionalFirstSignupBonus
      : 0;
 
  return firstTimeBonus + additionalFirstTimeBonus;
};