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 https://github.com/grafana/oncall/issues/1552

## Checklist

- [x] Tests updated
- [x] `CHANGELOG.md` updated
(public docs will be added in a separate PR)
This commit is contained in:
Vadim Stepanov 2023-03-27 12:03:16 +01:00 committed by GitHub
parent a2caeae3c7
commit a936c8c7fe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 430 additions and 179 deletions

View file

@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
using URLs without a top-level domain ([1266](https://github.com/grafana/oncall/pull/1266))
- Updated wording when creating an integration ([1572](https://github.com/grafana/oncall/pull/1572))
- Set FCM iOS/Android "message priority" to "high priority" for mobile app push notifications ([1612](https://github.com/grafana/oncall/pull/1612))
- Improve schedule quality feature (by @vadimkerr in [#1602](https://github.com/grafana/oncall/pull/1602))
### Fixed

View file

@ -27,7 +27,6 @@ from apps.auth_token.auth import PluginAuthentication
from apps.auth_token.constants import SCHEDULE_EXPORT_TOKEN_NAME
from apps.auth_token.models import ScheduleExportAuthToken
from apps.schedules.models import OnCallSchedule
from apps.schedules.quality_score import get_schedule_quality_score
from apps.slack.models import SlackChannel
from apps.slack.tasks import update_slack_user_group_for_schedules
from common.api_helpers.exceptions import BadRequest, Conflict
@ -353,13 +352,12 @@ class ScheduleView(
@action(detail=True, methods=["get"])
def quality(self, request, pk):
schedule = self.get_object()
user_tz, date = self.get_request_timezone()
days = int(self.request.query_params.get("days", 90)) # todo: check if days could be calculated more precisely
events = schedule.filter_events(user_tz, date, days=days, with_empty=True, with_gap=True)
_, date = self.get_request_timezone()
days = self.request.query_params.get("days")
days = int(days) if days else None
schedule_score = get_schedule_quality_score(events, days)
return Response(schedule_score)
return Response(schedule.quality_report(date, days))
@action(detail=False, methods=["get"])
def type_options(self, request):

View file

@ -1,6 +1,9 @@
import datetime
import functools
import itertools
from collections import defaultdict
from enum import Enum
from typing import Iterable, Optional, TypedDict
import icalendar
import pytz
@ -23,9 +26,33 @@ from apps.schedules.ical_utils import (
list_of_oncall_shifts_from_ical,
)
from apps.schedules.models import CustomOnCallShift
from apps.user_management.models import User
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
# Utility classes for schedule quality report
class QualityReportCommentType(str, Enum):
INFO = "info"
WARNING = "warning"
class QualityReportComment(TypedDict):
type: QualityReportCommentType
text: str
class QualityReportOverloadedUser(TypedDict):
id: str
username: str
score: int
class QualityReport(TypedDict):
total_score: int
comments: list[QualityReportComment]
overloaded_users: list[QualityReportOverloadedUser]
def generate_public_primary_key_for_oncall_schedule_channel():
prefix = "S"
new_public_primary_key = generate_public_primary_key(prefix)
@ -256,6 +283,126 @@ class OnCallSchedule(PolymorphicModel):
events = self._resolve_schedule(events)
return events
def quality_report(self, date: Optional[timezone.datetime], days: Optional[int]) -> QualityReport:
"""
Return schedule quality report to be used by the web UI.
TODO: Add scores on "inside working hours" and "balance outside working hours" when
TODO: working hours editor is implemented in the web UI.
"""
# get events to consider for calculation
if date is None:
today = datetime.datetime.now(tz=datetime.timezone.utc)
date = today - datetime.timedelta(days=7 - today.weekday()) # start of next week in UTC
if days is None:
days = 52 * 7 # consider next 52 weeks (~1 year)
events = self.final_events(user_tz="UTC", starting_date=date, days=days)
# an event is “good” if it's not a gap and not empty
good_events = [event for event in events if not event["is_gap"] and not event["is_empty"]]
if not good_events:
return {
"total_score": 0,
"comments": [{"type": QualityReportCommentType.WARNING, "text": "Schedule is empty"}],
"overloaded_users": [],
}
def event_duration(ev: dict) -> datetime.timedelta:
return ev["end"] - ev["start"]
def timedelta_sum(deltas: Iterable[datetime.timedelta]) -> datetime.timedelta:
return sum(deltas, start=datetime.timedelta())
def score_to_percent(value: float) -> int:
return round(value * 100)
def get_duration_map(evs: list[dict]) -> dict[str, datetime.timedelta]:
"""Return a map of user PKs to total duration of events they are in."""
result = defaultdict(datetime.timedelta)
for ev in evs:
for user in ev["users"]:
user_pk = user["pk"]
result[user_pk] += event_duration(ev)
return result
def get_balance_score_by_duration_map(dur_map: dict[str, datetime.timedelta]) -> float:
"""
Return a score between 0 and 1, based on how balanced the durations are in the duration map.
The formula is taken from https://github.com/grafana/oncall/issues/118#issuecomment-1161787854.
"""
if len(dur_map) <= 1:
return 1
result = 0
for key_1, key_2 in itertools.combinations(dur_map, 2):
duration_1 = dur_map[key_1]
duration_2 = dur_map[key_2]
result += min(duration_1, duration_2) / max(duration_1, duration_2)
number_of_pairs = len(dur_map) * (len(dur_map) - 1) // 2
return result / number_of_pairs
# calculate good event score
good_events_duration = timedelta_sum(event_duration(event) for event in good_events)
good_event_score = min(good_events_duration / datetime.timedelta(days=days), 1)
good_event_score = score_to_percent(good_event_score)
# calculate balance score
duration_map = get_duration_map(good_events)
balance_score = get_balance_score_by_duration_map(duration_map)
balance_score = score_to_percent(balance_score)
# calculate overloaded users
if balance_score >= 95: # tolerate minor imbalance
balance_score = 100
overloaded_users = []
else:
average_duration = timedelta_sum(duration_map.values()) / len(duration_map)
overloaded_user_pks = [user_pk for user_pk, duration in duration_map.items() if duration > average_duration]
usernames = {
u.public_primary_key: u.username
for u in User.objects.filter(public_primary_key__in=overloaded_user_pks).only(
"public_primary_key", "username"
)
}
overloaded_users = []
for user_pk in overloaded_user_pks:
score = score_to_percent(duration_map[user_pk] / average_duration) - 100
username = usernames.get(user_pk) or "unknown" # fallback to "unknown" if user is not found
overloaded_users.append({"id": user_pk, "username": username, "score": score})
# show most overloaded users first
overloaded_users.sort(key=lambda u: (-u["score"], u["username"]))
# generate comments regarding gaps
comments = []
if good_event_score == 100:
comments.append({"type": QualityReportCommentType.INFO, "text": "Schedule has no gaps"})
else:
not_covered = 100 - good_event_score
comments.append(
{"type": QualityReportCommentType.WARNING, "text": f"Schedule has gaps ({not_covered}% not covered)"}
)
# generate comments regarding balance
if balance_score == 100:
comments.append({"type": QualityReportCommentType.INFO, "text": "Schedule is perfectly balanced"})
else:
comments.append(
{"type": QualityReportCommentType.WARNING, "text": "Schedule has balance issues (see overloaded users)"}
)
# calculate total score (weighted sum of good event score and balance score)
total_score = round((good_event_score + balance_score) / 2)
return {
"total_score": total_score,
"comments": comments,
"overloaded_users": overloaded_users,
}
def _resolve_schedule(self, events):
"""Calculate final schedule shifts considering rotations and overrides."""
if not events:

View file

@ -1,117 +0,0 @@
import datetime
import enum
import itertools
from collections import defaultdict
from typing import Iterable, Union
import pytz
class CommentType(str, enum.Enum):
INFO = "info"
WARNING = "warning"
# TODO: add "inside working hours score" and "balance outside working hours score" when working hours editor is implemented
def get_schedule_quality_score(events: list[dict], days: int) -> dict:
# an event is “good” if it's a primary event, not a gap and not empty
good_events = [
event for event in events if not event["is_override"] and not event["is_gap"] and not event["is_empty"]
]
good_event_score = get_good_event_score(good_events, days)
# formula for balance score is taken from here: https://github.com/grafana/oncall/issues/118
balance_score, overloaded_users = get_balance_score(good_events)
if events:
total_score = (good_event_score + balance_score) / 2
else:
total_score = 0
comments = []
if good_event_score < 1:
comments.append({"type": CommentType.WARNING, "text": "Schedule has gaps"})
else:
comments.append({"type": CommentType.INFO, "text": "Schedule has no gaps"})
if balance_score < 0.8:
comments.append({"type": CommentType.WARNING, "text": "Schedule has balance issues"})
elif 0.8 <= balance_score < 1:
comments.append({"type": CommentType.INFO, "text": "Schedule is well-balanced, but still can be improved"})
else:
comments.append({"type": CommentType.INFO, "text": "Schedule is perfectly balanced"})
return {
"total_score": score_to_percent(total_score),
"comments": comments,
"overloaded_users": overloaded_users,
}
def get_good_event_score(good_events: list[dict], days: int) -> float:
good_events_duration = timedelta_sum(event_duration(event) for event in good_events)
good_event_score = min(
good_events_duration / datetime.timedelta(days=days), 1
) # todo: deal with overlapping events
return good_event_score
def get_balance_score(events: list[dict]) -> tuple[float, list[str]]:
duration_map = defaultdict(datetime.timedelta)
for event in events:
for user in event["users"]:
user_pk = user["pk"]
duration_map[user_pk] += event_duration(event)
if len(duration_map) == 0:
return 1, []
average_duration = timedelta_sum(duration_map.values()) / len(duration_map)
overloaded_users = [user_pk for user_pk, duration in duration_map.items() if duration > average_duration]
return get_balance_score_by_duration_map(duration_map), overloaded_users
def get_balance_score_by_duration_map(duration_map: dict[str, datetime.timedelta]) -> float:
if len(duration_map) <= 1:
return 1
score = 0
for key_1, key_2 in itertools.combinations(duration_map, 2):
duration_1 = duration_map[key_1]
duration_2 = duration_map[key_2]
score += min(duration_1, duration_2) / max(duration_1, duration_2)
number_of_pairs = len(duration_map) * (len(duration_map) - 1) // 2
balance_score = score / number_of_pairs
return balance_score
def get_day_start(dt: Union[datetime.datetime, datetime.date]) -> datetime.datetime:
return datetime.datetime.combine(dt, datetime.datetime.min.time(), tzinfo=pytz.UTC)
def get_day_end(dt: Union[datetime.datetime, datetime.date]) -> datetime.datetime:
return datetime.datetime.combine(dt, datetime.datetime.max.time(), tzinfo=pytz.UTC)
def event_duration(event: dict) -> datetime.timedelta:
start = event["start"]
end = event["end"]
if event["all_day"]:
start = get_day_start(start)
# adding one microsecond to the end datetime to make sure 1 day-long events are really 1 day long
end = get_day_end(end) + datetime.timedelta(microseconds=1)
return end - start
def timedelta_sum(deltas: Iterable[datetime.timedelta]) -> datetime.timedelta:
return sum(deltas, start=datetime.timedelta())
def score_to_percent(score: float) -> int:
return round(score * 100)

View file

@ -1,10 +1,12 @@
import datetime
import pytest
from rest_framework import status
from rest_framework.reverse import reverse
from rest_framework.test import APIClient
from apps.schedules.ical_utils import memoized_users_in_ical
from apps.schedules.models import OnCallScheduleICal
from apps.schedules.models import CustomOnCallShift, OnCallScheduleICal, OnCallScheduleWeb
@pytest.fixture
@ -54,8 +56,7 @@ def test_get_schedule_score_no_events(get_schedule_quality_response):
assert response.json() == {
"total_score": 0,
"comments": [
{"type": "warning", "text": "Schedule has gaps"},
{"type": "info", "text": "Schedule is perfectly balanced"},
{"type": "warning", "text": "Schedule is empty"},
],
"overloaded_users": [],
}
@ -67,12 +68,18 @@ def test_get_schedule_score_09_05(get_schedule_quality_response):
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"total_score": 27,
"total_score": 28,
"comments": [
{"type": "warning", "text": "Schedule has gaps"},
{"type": "warning", "text": "Schedule has balance issues"},
{"type": "warning", "text": "Schedule has gaps (79% not covered)"},
{"type": "warning", "text": "Schedule has balance issues (see overloaded users)"},
],
"overloaded_users": [
{
"id": user1.public_primary_key,
"username": user1.username,
"score": 49,
},
],
"overloaded_users": [user1.public_primary_key],
}
@ -84,10 +91,16 @@ def test_get_schedule_score_09_09(get_schedule_quality_response):
assert response.json() == {
"total_score": 51,
"comments": [
{"type": "warning", "text": "Schedule has gaps"},
{"type": "info", "text": "Schedule is well-balanced, but still can be improved"},
{"type": "warning", "text": "Schedule has gaps (81% not covered)"},
{"type": "warning", "text": "Schedule has balance issues (see overloaded users)"},
],
"overloaded_users": [
{
"id": user2.public_primary_key,
"username": user2.username,
"score": 9,
},
],
"overloaded_users": [user2.public_primary_key],
}
@ -113,8 +126,233 @@ def test_get_schedule_score_09_19(get_schedule_quality_response):
assert response.json() == {
"total_score": 70,
"comments": [
{"type": "warning", "text": "Schedule has gaps"},
{"type": "warning", "text": "Schedule has gaps (59% not covered)"},
{"type": "info", "text": "Schedule is perfectly balanced"},
],
"overloaded_users": [],
}
@pytest.mark.django_db
def test_get_schedule_score_weekdays(
make_organization,
make_user_for_organization,
make_token_for_organization,
make_schedule,
make_on_call_shift,
make_user_auth_headers,
):
organization = make_organization()
_, token = make_token_for_organization(organization)
schedule = make_schedule(
organization,
schedule_class=OnCallScheduleWeb,
name="test_quality",
)
users = [make_user_for_organization(organization, username=f"user-{idx}") for idx in range(8)]
# clear users pks <-> organization cache (persisting between tests)
memoized_users_in_ical.cache_clear()
make_on_call_shift(
schedule.organization,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
schedule=schedule,
start=datetime.datetime(2022, 3, 20, 0, 0, 0, tzinfo=datetime.timezone.utc),
duration=datetime.timedelta(hours=12),
rotation_start=datetime.datetime(2022, 3, 20, 0, 0, 0, tzinfo=datetime.timezone.utc),
until=None,
rolling_users=[{user.pk: user.public_primary_key for user in users[:4]}],
frequency=CustomOnCallShift.FREQUENCY_WEEKLY,
by_day=["MO", "TU", "WE", "TH", "FR"],
)
make_on_call_shift(
schedule.organization,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
schedule=schedule,
start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
duration=datetime.timedelta(hours=12),
rotation_start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
until=None,
rolling_users=[{user.pk: user.public_primary_key for user in users[4:]}],
frequency=CustomOnCallShift.FREQUENCY_WEEKLY,
by_day=["MO", "TU", "WE", "TH", "FR"],
)
client = APIClient()
url = reverse("api-internal:schedule-quality", kwargs={"pk": schedule.public_primary_key}) + "?date=2022-03-24"
response = client.get(url, **make_user_auth_headers(users[0], token))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"total_score": 86,
"comments": [
{"type": "warning", "text": "Schedule has gaps (29% not covered)"},
{"type": "info", "text": "Schedule is perfectly balanced"},
],
"overloaded_users": [],
}
@pytest.mark.django_db
def test_get_schedule_score_all_week(
make_organization,
make_user_for_organization,
make_token_for_organization,
make_schedule,
make_on_call_shift,
make_user_auth_headers,
):
organization = make_organization()
_, token = make_token_for_organization(organization)
schedule = make_schedule(
organization,
schedule_class=OnCallScheduleWeb,
name="test_quality",
)
users = [make_user_for_organization(organization, username=f"user-{idx}") for idx in range(8)]
# clear users pks <-> organization cache (persisting between tests)
memoized_users_in_ical.cache_clear()
make_on_call_shift(
schedule.organization,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
schedule=schedule,
start=datetime.datetime(2022, 3, 20, 0, 0, 0, tzinfo=datetime.timezone.utc),
duration=datetime.timedelta(hours=12),
rotation_start=datetime.datetime(2022, 3, 20, 0, 0, 0, tzinfo=datetime.timezone.utc),
until=None,
rolling_users=[{user.pk: user.public_primary_key for user in users[:4]}],
frequency=CustomOnCallShift.FREQUENCY_WEEKLY,
by_day=["MO", "TU", "WE", "TH", "FR"],
)
make_on_call_shift(
schedule.organization,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
schedule=schedule,
start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
duration=datetime.timedelta(hours=12),
rotation_start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
until=None,
rolling_users=[{user.pk: user.public_primary_key for user in users[4:]}],
frequency=CustomOnCallShift.FREQUENCY_WEEKLY,
by_day=["MO", "TU", "WE", "TH", "FR"],
)
make_on_call_shift(
schedule.organization,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
schedule=schedule,
start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
duration=datetime.timedelta(hours=24),
rotation_start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
until=None,
rolling_users=[{user.pk: user.public_primary_key for user in users}],
frequency=CustomOnCallShift.FREQUENCY_WEEKLY,
by_day=["SA", "SU"],
)
client = APIClient()
url = reverse("api-internal:schedule-quality", kwargs={"pk": schedule.public_primary_key}) + "?date=2022-03-24"
response = client.get(url, **make_user_auth_headers(users[0], token))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"total_score": 100,
"comments": [
{"type": "info", "text": "Schedule has no gaps"},
{"type": "info", "text": "Schedule is perfectly balanced"},
],
"overloaded_users": [],
}
@pytest.mark.django_db
def test_get_schedule_score_all_week_imbalanced_weekends(
make_organization,
make_user_for_organization,
make_token_for_organization,
make_schedule,
make_on_call_shift,
make_user_auth_headers,
):
organization = make_organization()
_, token = make_token_for_organization(organization)
schedule = make_schedule(
organization,
schedule_class=OnCallScheduleWeb,
name="test_quality",
)
users = [make_user_for_organization(organization, username=f"user-{idx}") for idx in range(8)]
# clear users pks <-> organization cache (persisting between tests)
memoized_users_in_ical.cache_clear()
make_on_call_shift(
schedule.organization,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
schedule=schedule,
start=datetime.datetime(2022, 3, 20, 0, 0, 0, tzinfo=datetime.timezone.utc),
duration=datetime.timedelta(hours=12),
rotation_start=datetime.datetime(2022, 3, 20, 0, 0, 0, tzinfo=datetime.timezone.utc),
until=None,
rolling_users=[{user.pk: user.public_primary_key for user in users[:4]}],
frequency=CustomOnCallShift.FREQUENCY_WEEKLY,
by_day=["MO", "TU", "WE", "TH", "FR"],
)
make_on_call_shift(
schedule.organization,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
schedule=schedule,
start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
duration=datetime.timedelta(hours=12),
rotation_start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
until=None,
rolling_users=[{user.pk: user.public_primary_key for user in users[4:]}],
frequency=CustomOnCallShift.FREQUENCY_WEEKLY,
by_day=["MO", "TU", "WE", "TH", "FR"],
)
make_on_call_shift(
schedule.organization,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
schedule=schedule,
start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
duration=datetime.timedelta(hours=24),
rotation_start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
until=None,
rolling_users=[{user.pk: user.public_primary_key for user in users[:4]}],
frequency=CustomOnCallShift.FREQUENCY_WEEKLY,
by_day=["SA", "SU"],
)
client = APIClient()
url = reverse("api-internal:schedule-quality", kwargs={"pk": schedule.public_primary_key}) + "?date=2022-03-24"
response = client.get(url, **make_user_auth_headers(users[0], token))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"total_score": 88,
"comments": [
{"type": "info", "text": "Schedule has no gaps"},
{"type": "warning", "text": "Schedule has balance issues (see overloaded users)"},
],
"overloaded_users": [
{
"id": user.public_primary_key,
"username": user.username,
"score": 29,
}
for user in users[:4]
],
}

View file

@ -0,0 +1,19 @@
import { test, expect } from '@playwright/test';
import { configureOnCallPlugin } from '../utils/configurePlugin';
import { generateRandomValue } from '../utils/forms';
import { createOnCallSchedule } from '../utils/schedule';
test.beforeEach(async ({ page }) => {
await configureOnCallPlugin(page);
});
test('check schedule quality for simple 1-user schedule', async ({ page }) => {
const onCallScheduleName = generateRandomValue();
await createOnCallSchedule(page, onCallScheduleName);
await expect(page.locator('div[class*="ScheduleQuality"]')).toHaveText('Quality: Great');
await page.hover('div[class*="ScheduleQuality"]');
await expect(page.locator('div[class*="ScheduleQualityDetails"] >> span[class*="Text"] >> nth=2 ')).toHaveText('Schedule has no gaps');
await expect(page.locator('div[class*="ScheduleQualityDetails"] >> span[class*="Text"] >> nth=3 ')).toHaveText('Schedule is perfectly balanced');
});

View file

@ -1,5 +1,5 @@
$padding: 8px;
$width: 280px;
$width: 340px;
.root {
width: $width;
@ -53,7 +53,7 @@ $width: 280px;
padding-left: 24px;
}
.email {
.username {
max-width: calc($width - $padding);
white-space: nowrap;
text-overflow: ellipsis;

View file

@ -1,14 +1,10 @@
import React, { FC, useCallback, useEffect, useState } from 'react';
import React, { FC, useCallback, useState } from 'react';
import { HorizontalGroup, Icon, IconButton } from '@grafana/ui';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import Text from 'components/Text/Text';
import { ScheduleScoreQualityResponse, ScheduleScoreQualityResult } from 'models/schedule/schedule.types';
import { getTzOffsetString } from 'models/timezone/timezone.helpers';
import { User } from 'models/user/user.types';
import { useStore } from 'state/useStore';
import { getVar } from 'utils/DOM';
import styles from './ScheduleQualityDetails.module.scss';
@ -22,24 +18,13 @@ interface ScheduleQualityDetailsProps {
}
export const ScheduleQualityDetails: FC<ScheduleQualityDetailsProps> = ({ quality, getScheduleQualityString }) => {
const { userStore } = useStore();
const { total_score: score, comments, overloaded_users } = quality;
const [expanded, setExpanded] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [overloadedUsers, setOverloadedUsers] = useState<User[]>([]);
useEffect(() => {
fetchUsers();
}, []);
const handleExpandClick = useCallback(() => {
setExpanded((expanded) => !expanded);
}, []);
if (isLoading) {
return null;
}
const infoComments = comments.filter((c) => c.type === 'info');
const warningComments = comments.filter((c) => c.type === 'warning');
@ -94,15 +79,15 @@ export const ScheduleQualityDetails: FC<ScheduleQualityDetailsProps> = ({ qualit
</>
)}
{overloadedUsers?.length > 0 && (
{overloaded_users?.length > 0 && (
<div className={cx('container')}>
<div className={cx('row')}>
<Icon name="users-alt" />
<div className={cx('container')}>
<Text type="secondary">Overloaded users</Text>
{overloadedUsers.map((overloadedUser, index) => (
<Text type="primary" className={cx('email')} key={index}>
{overloadedUser.email} ({getTzOffsetString(dayjs().tz(overloadedUser.timezone))})
{overloaded_users.map((overloadedUser, index) => (
<Text type="primary" className={cx('username')} key={index}>
{overloadedUser.username} (+{overloadedUser.score}% avg)
</Text>
))}
</div>
@ -125,7 +110,7 @@ export const ScheduleQualityDetails: FC<ScheduleQualityDetailsProps> = ({ qualit
</HorizontalGroup>
{expanded && (
<Text type="primary" className={cx('text')}>
The next 90 days are taken into consideration when calculating the overall schedule quality.
The next 52 weeks are taken into consideration when calculating the overall schedule quality.
</Text>
)}
</div>
@ -133,25 +118,6 @@ export const ScheduleQualityDetails: FC<ScheduleQualityDetailsProps> = ({ qualit
</div>
);
async function fetchUsers() {
if (!overloaded_users?.length) {
setIsLoading(false);
return;
}
const allUsersList: User[] = userStore.getSearchResult().results;
const overloadedUsers = [];
allUsersList.forEach((user) => {
if (overloaded_users.indexOf(user['pk']) !== -1) {
overloadedUsers.push(user);
}
});
setIsLoading(false);
setOverloadedUsers(overloadedUsers);
}
function getScheduleQualityMatchingColor(score: number): string {
if (score < 20) {
return getVar('--tag-text-danger');

View file

@ -188,8 +188,7 @@ export class ScheduleStore extends BaseStore {
}
async getScoreQuality(scheduleId: Schedule['id']): Promise<ScheduleScoreQualityResponse> {
const tomorrow = getFromString(dayjs().add(1, 'day'));
return await makeRequest(`/schedules/${scheduleId}/quality?date=${tomorrow}`, { method: 'GET' });
return await makeRequest(`/schedules/${scheduleId}/quality`, { method: 'GET' });
}
@action

View file

@ -115,7 +115,7 @@ export interface ShiftEvents {
export interface ScheduleScoreQualityResponse {
total_score: number;
comments: Array<{ type: 'warning' | 'info'; text: string }>;
overloaded_users: string[];
overloaded_users: Array<{ id: string; username: string; score: number }>;
}
export enum ScheduleScoreQualityResult {