Skip to content

Commit 2aaa559

Browse files
authored
Improve schedule quality feature (#1602)
# What this PR does Before: <img width="281" alt="Screenshot 2023-03-23 at 16 56 42" src="https://user-images.githubusercontent.com/20116910/227279464-c883ec05-a964-4360-bda2-3443409ca90a.png"> After: <img width="338" alt="Screenshot 2023-03-23 at 16 57 41" src="https://user-images.githubusercontent.com/20116910/227279476-468bffba-922a-45ea-b400-5f34d6bf0534.png"> - Add scores for overloaded users, e.g. `(+25% avg)` which means the user is scheduled to be on-call 25% more than average for given schedule. - Add score for gaps, e.g. `Schedule has gaps (29% not covered)` which means 29% of time no one is scheduled to be on-call. - Make things easier to understand when there are gaps in the schedule, add `(see overloaded users)` text. - Consider events for next 52 weeks (~1 year) instead of 90 days (~3 months), so the quality report is more accurate. Also treat any balance quality >95% as perfectly balanced. These two changes (period change and adding 95% threshold) should help eliminate false positives for _most_ schedules. - Modify backend & frontend so the backend returns all necessary user information to render without using the user store. - Move quality report generation to `OnCallSchedule` model, add more tests. ## Which issue(s) this PR fixes Related to #1552 ## Checklist - [x] Tests updated - [x] `CHANGELOG.md` updated (public docs will be added in a separate PR)
1 parent f095cbe commit 2aaa559

File tree

5 files changed

+29
-45
lines changed

5 files changed

+29
-45
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { test, expect } from '@playwright/test';
2+
import { configureOnCallPlugin } from '../utils/configurePlugin';
3+
import { generateRandomValue } from '../utils/forms';
4+
import { createOnCallSchedule } from '../utils/schedule';
5+
6+
test.beforeEach(async ({ page }) => {
7+
await configureOnCallPlugin(page);
8+
});
9+
10+
test('check schedule quality for simple 1-user schedule', async ({ page }) => {
11+
const onCallScheduleName = generateRandomValue();
12+
await createOnCallSchedule(page, onCallScheduleName);
13+
14+
await expect(page.locator('div[class*="ScheduleQuality"]')).toHaveText('Quality: Great');
15+
16+
await page.hover('div[class*="ScheduleQuality"]');
17+
await expect(page.locator('div[class*="ScheduleQualityDetails"] >> span[class*="Text"] >> nth=2 ')).toHaveText('Schedule has no gaps');
18+
await expect(page.locator('div[class*="ScheduleQualityDetails"] >> span[class*="Text"] >> nth=3 ')).toHaveText('Schedule is perfectly balanced');
19+
});

src/components/ScheduleQualityDetails/ScheduleQualityDetails.module.scss

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
$padding: 8px;
2-
$width: 280px;
2+
$width: 340px;
33

44
.root {
55
width: $width;
@@ -53,7 +53,7 @@ $width: 280px;
5353
padding-left: 24px;
5454
}
5555

56-
.email {
56+
.username {
5757
max-width: calc($width - $padding);
5858
white-space: nowrap;
5959
text-overflow: ellipsis;

src/components/ScheduleQualityDetails/ScheduleQualityDetails.tsx

+6-40
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
1-
import React, { FC, useCallback, useEffect, useState } from 'react';
1+
import React, { FC, useCallback, useState } from 'react';
22

33
import { HorizontalGroup, Icon, IconButton } from '@grafana/ui';
44
import cn from 'classnames/bind';
5-
import dayjs from 'dayjs';
65

76
import Text from 'components/Text/Text';
87
import { ScheduleScoreQualityResponse, ScheduleScoreQualityResult } from 'models/schedule/schedule.types';
9-
import { getTzOffsetString } from 'models/timezone/timezone.helpers';
10-
import { User } from 'models/user/user.types';
11-
import { useStore } from 'state/useStore';
128
import { getVar } from 'utils/DOM';
139

1410
import styles from './ScheduleQualityDetails.module.scss';
@@ -22,24 +18,13 @@ interface ScheduleQualityDetailsProps {
2218
}
2319

2420
export const ScheduleQualityDetails: FC<ScheduleQualityDetailsProps> = ({ quality, getScheduleQualityString }) => {
25-
const { userStore } = useStore();
2621
const { total_score: score, comments, overloaded_users } = quality;
2722
const [expanded, setExpanded] = useState<boolean>(false);
28-
const [isLoading, setIsLoading] = useState<boolean>(true);
29-
const [overloadedUsers, setOverloadedUsers] = useState<User[]>([]);
30-
31-
useEffect(() => {
32-
fetchUsers();
33-
}, []);
3423

3524
const handleExpandClick = useCallback(() => {
3625
setExpanded((expanded) => !expanded);
3726
}, []);
3827

39-
if (isLoading) {
40-
return null;
41-
}
42-
4328
const infoComments = comments.filter((c) => c.type === 'info');
4429
const warningComments = comments.filter((c) => c.type === 'warning');
4530

@@ -94,15 +79,15 @@ export const ScheduleQualityDetails: FC<ScheduleQualityDetailsProps> = ({ qualit
9479
</>
9580
)}
9681

97-
{overloadedUsers?.length > 0 && (
82+
{overloaded_users?.length > 0 && (
9883
<div className={cx('container')}>
9984
<div className={cx('row')}>
10085
<Icon name="users-alt" />
10186
<div className={cx('container')}>
10287
<Text type="secondary">Overloaded users</Text>
103-
{overloadedUsers.map((overloadedUser, index) => (
104-
<Text type="primary" className={cx('email')} key={index}>
105-
{overloadedUser.email} ({getTzOffsetString(dayjs().tz(overloadedUser.timezone))})
88+
{overloaded_users.map((overloadedUser, index) => (
89+
<Text type="primary" className={cx('username')} key={index}>
90+
{overloadedUser.username} (+{overloadedUser.score}% avg)
10691
</Text>
10792
))}
10893
</div>
@@ -125,33 +110,14 @@ export const ScheduleQualityDetails: FC<ScheduleQualityDetailsProps> = ({ qualit
125110
</HorizontalGroup>
126111
{expanded && (
127112
<Text type="primary" className={cx('text')}>
128-
The next 90 days are taken into consideration when calculating the overall schedule quality.
113+
The next 52 weeks are taken into consideration when calculating the overall schedule quality.
129114
</Text>
130115
)}
131116
</div>
132117
</div>
133118
</div>
134119
);
135120

136-
async function fetchUsers() {
137-
if (!overloaded_users?.length) {
138-
setIsLoading(false);
139-
return;
140-
}
141-
142-
const allUsersList: User[] = userStore.getSearchResult().results;
143-
const overloadedUsers = [];
144-
145-
allUsersList.forEach((user) => {
146-
if (overloaded_users.indexOf(user['pk']) !== -1) {
147-
overloadedUsers.push(user);
148-
}
149-
});
150-
151-
setIsLoading(false);
152-
setOverloadedUsers(overloadedUsers);
153-
}
154-
155121
function getScheduleQualityMatchingColor(score: number): string {
156122
if (score < 20) {
157123
return getVar('--tag-text-danger');

src/models/schedule/schedule.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,7 @@ export class ScheduleStore extends BaseStore {
188188
}
189189

190190
async getScoreQuality(scheduleId: Schedule['id']): Promise<ScheduleScoreQualityResponse> {
191-
const tomorrow = getFromString(dayjs().add(1, 'day'));
192-
return await makeRequest(`/schedules/${scheduleId}/quality?date=${tomorrow}`, { method: 'GET' });
191+
return await makeRequest(`/schedules/${scheduleId}/quality`, { method: 'GET' });
193192
}
194193

195194
@action

src/models/schedule/schedule.types.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ export interface ShiftEvents {
115115
export interface ScheduleScoreQualityResponse {
116116
total_score: number;
117117
comments: Array<{ type: 'warning' | 'info'; text: string }>;
118-
overloaded_users: string[];
118+
overloaded_users: Array<{ id: string; username: string; score: number }>;
119119
}
120120

121121
export enum ScheduleScoreQualityResult {

0 commit comments

Comments
 (0)