diff --git a/CHANGELOG.md b/CHANGELOG.md index a0c5f790..5590c2df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/engine/apps/api/views/schedule.py b/engine/apps/api/views/schedule.py index b66d28f5..76d8d6ac 100644 --- a/engine/apps/api/views/schedule.py +++ b/engine/apps/api/views/schedule.py @@ -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): diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 4e01ef61..f1ddb17f 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -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: diff --git a/engine/apps/schedules/quality_score.py b/engine/apps/schedules/quality_score.py deleted file mode 100644 index b152e0cc..00000000 --- a/engine/apps/schedules/quality_score.py +++ /dev/null @@ -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) diff --git a/engine/apps/schedules/tests/test_quality_score.py b/engine/apps/schedules/tests/test_quality_score.py index 4d313c27..5926f929 100644 --- a/engine/apps/schedules/tests/test_quality_score.py +++ b/engine/apps/schedules/tests/test_quality_score.py @@ -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] + ], + } diff --git a/grafana-plugin/integration-tests/schedules/quality.test.ts b/grafana-plugin/integration-tests/schedules/quality.test.ts new file mode 100644 index 00000000..952e22d8 --- /dev/null +++ b/grafana-plugin/integration-tests/schedules/quality.test.ts @@ -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'); +}); diff --git a/grafana-plugin/src/components/ScheduleQualityDetails/ScheduleQualityDetails.module.scss b/grafana-plugin/src/components/ScheduleQualityDetails/ScheduleQualityDetails.module.scss index 97e6b8f6..1469c4ec 100644 --- a/grafana-plugin/src/components/ScheduleQualityDetails/ScheduleQualityDetails.module.scss +++ b/grafana-plugin/src/components/ScheduleQualityDetails/ScheduleQualityDetails.module.scss @@ -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; diff --git a/grafana-plugin/src/components/ScheduleQualityDetails/ScheduleQualityDetails.tsx b/grafana-plugin/src/components/ScheduleQualityDetails/ScheduleQualityDetails.tsx index 89076418..b2fa2775 100644 --- a/grafana-plugin/src/components/ScheduleQualityDetails/ScheduleQualityDetails.tsx +++ b/grafana-plugin/src/components/ScheduleQualityDetails/ScheduleQualityDetails.tsx @@ -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 = ({ quality, getScheduleQualityString }) => { - const { userStore } = useStore(); const { total_score: score, comments, overloaded_users } = quality; const [expanded, setExpanded] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [overloadedUsers, setOverloadedUsers] = useState([]); - - 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 = ({ qualit )} - {overloadedUsers?.length > 0 && ( + {overloaded_users?.length > 0 && (
Overloaded users - {overloadedUsers.map((overloadedUser, index) => ( - - {overloadedUser.email} ({getTzOffsetString(dayjs().tz(overloadedUser.timezone))}) + {overloaded_users.map((overloadedUser, index) => ( + + {overloadedUser.username} (+{overloadedUser.score}% avg) ))}
@@ -125,7 +110,7 @@ export const ScheduleQualityDetails: FC = ({ qualit {expanded && ( - 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. )}
@@ -133,25 +118,6 @@ export const ScheduleQualityDetails: FC = ({ qualit
); - 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'); diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index e69b23bf..5c7a8da8 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -188,8 +188,7 @@ export class ScheduleStore extends BaseStore { } async getScoreQuality(scheduleId: Schedule['id']): Promise { - 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 diff --git a/grafana-plugin/src/models/schedule/schedule.types.ts b/grafana-plugin/src/models/schedule/schedule.types.ts index 827ceca7..df0c7500 100644 --- a/grafana-plugin/src/models/schedule/schedule.types.ts +++ b/grafana-plugin/src/models/schedule/schedule.types.ts @@ -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 {