From 9d16efadecd064207c5fc78865e501ed962308a1 Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 26 Aug 2026 09:21:34 +0300 Subject: [PATCH] feat: add secret skill anti-streaks --- .../api/v1/employees/serializers/shared.py | 43 ++- .../commands/recalculate_secret_skills.py | 6 +- backend/employees/services/secret_skills.py | 272 +++++++++++++++--- backend/tests/api/v1/employees/test_skills.py | 4 + backend/tests/employees/test_secret_skills.py | 122 +++++++- .../src/components/users/ProfileSections.tsx | 44 ++- frontend/src/types/api.ts | 12 + 7 files changed, 455 insertions(+), 48 deletions(-) diff --git a/backend/api/v1/employees/serializers/shared.py b/backend/api/v1/employees/serializers/shared.py index a821380b..af993ee9 100644 --- a/backend/api/v1/employees/serializers/shared.py +++ b/backend/api/v1/employees/serializers/shared.py @@ -1,7 +1,9 @@ """Общие (shared) сериализаторы: Skill, Position, EmployeeAction.""" -from drf_spectacular.utils import extend_schema_field from django.contrib.auth.models import Group +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + from employees.constants import ACTION_CHOICES, TEMPORARY_START_ACTIONS from employees.models import ( EmployeeAction, @@ -10,15 +12,14 @@ from employees.models import ( Skill, SkillExperienceEvent, ) -from employees.services.skill_progress import ( - confirmed_holder_count, - rank_payload, -) from employees.services.secret_skills import ( secret_skill_progress_payload, skill_rarity_payload, ) -from rest_framework import serializers +from employees.services.skill_progress import ( + confirmed_holder_count, + rank_payload, +) class GroupVerboseSerializer(serializers.Serializer): @@ -54,6 +55,12 @@ class SkillSerializer(serializers.ModelSerializer): progress_label = serializers.SerializerMethodField() progress_data = serializers.SerializerMethodField() next_rank_requirements = serializers.SerializerMethodField() + anti_streak = serializers.SerializerMethodField() + anti_streak_limit = serializers.SerializerMethodField() + anti_streak_remaining = serializers.SerializerMethodField() + recovery_streak = serializers.SerializerMethodField() + recovery_streak_target = serializers.SerializerMethodField() + penalty_levels = serializers.SerializerMethodField() class Meta: model = Skill @@ -77,6 +84,12 @@ class SkillSerializer(serializers.ModelSerializer): "progress_label", "progress_data", "next_rank_requirements", + "anti_streak", + "anti_streak_limit", + "anti_streak_remaining", + "recovery_streak", + "recovery_streak_target", + "penalty_levels", ] read_only_fields = ["is_secret", "progression_type"] @@ -141,6 +154,24 @@ class SkillSerializer(serializers.ModelSerializer): def get_next_rank_requirements(self, obj): return self._rank(obj).get("next_rank_requirements", []) + def get_anti_streak(self, obj): + return self._rank(obj).get("anti_streak", 0) + + def get_anti_streak_limit(self, obj): + return self._rank(obj).get("anti_streak_limit", 0) + + def get_anti_streak_remaining(self, obj): + return self._rank(obj).get("anti_streak_remaining", 0) + + def get_recovery_streak(self, obj): + return self._rank(obj).get("recovery_streak", 0) + + def get_recovery_streak_target(self, obj): + return self._rank(obj).get("recovery_streak_target", 0) + + def get_penalty_levels(self, obj): + return self._rank(obj).get("penalty_levels", 0) + class SkillExperienceEventSerializer(serializers.ModelSerializer): source_display = serializers.CharField( diff --git a/backend/employees/management/commands/recalculate_secret_skills.py b/backend/employees/management/commands/recalculate_secret_skills.py index c9cfb6a7..81fb2c27 100644 --- a/backend/employees/management/commands/recalculate_secret_skills.py +++ b/backend/employees/management/commands/recalculate_secret_skills.py @@ -24,15 +24,15 @@ class Command(BaseCommand): employee_ids = employee_ids.filter(employee_id=employee_id) processed = 0 - unlocked_or_promoted = 0 + rank_changes = 0 for current_employee_id in employee_ids.iterator(): changes = recalculate_attendance_secret_skills(current_employee_id) processed += 1 - unlocked_or_promoted += len(changes) + rank_changes += len(changes) self.stdout.write( self.style.SUCCESS( "Пересчитано сотрудников: " - f"{processed}; открыто или повышено навыков: {unlocked_or_promoted}" + f"{processed}; изменений рангов и навыков: {rank_changes}" ) ) diff --git a/backend/employees/services/secret_skills.py b/backend/employees/services/secret_skills.py index 5430ee94..0ef9ba67 100644 --- a/backend/employees/services/secret_skills.py +++ b/backend/employees/services/secret_skills.py @@ -12,7 +12,6 @@ from employees.constants import ACTION_REMOTE from employees.models import Employee, EmployeeSkillProgress, Skill from employees.services.skill_progress import SKILL_RANKS, rarity_payload - RANK_CODES = tuple(rank["code"] for rank in SKILL_RANKS) RANK_LABELS = {rank["code"]: rank["label"] for rank in SKILL_RANKS} RANK_INDEX = {code: index for index, code in enumerate(RANK_CODES)} @@ -73,6 +72,8 @@ class SecretSkillDefinition: name: str progression_type: str requirements: dict[str, dict[str, int]] + anti_streak_limit: int + recovery_streak_target: int SECRET_SKILL_DEFINITIONS = ( @@ -81,12 +82,16 @@ SECRET_SKILL_DEFINITIONS = ( name="Пунктуальность", progression_type=Skill.ProgressionType.ATTENDANCE_PUNCTUALITY, requirements=PUNCTUALITY_REQUIREMENTS, + anti_streak_limit=3, + recovery_streak_target=20, ), SecretSkillDefinition( system_code="attendance_self_organization", name="Самоорганизация", progression_type=Skill.ProgressionType.ATTENDANCE_SELF_ORGANIZATION, requirements=SELF_ORGANIZATION_REQUIREMENTS, + anti_streak_limit=5, + recovery_streak_target=30, ), ) @@ -99,7 +104,78 @@ def _has_work(record) -> bool: ) -def calculate_attendance_secret_metrics(records: Iterable) -> dict[str, int | str]: +def _rank_metrics(metrics: dict[str, object], definition: SecretSkillDefinition): + if definition.progression_type == Skill.ProgressionType.ATTENDANCE_PUNCTUALITY: + return {"best_streak": metrics["best_streak"]} + return { + "clean_days": metrics["clean_days"], + "best_clean_streak": metrics["best_clean_streak"], + "off_schedule_days": metrics["off_schedule_days"], + "overtime_days": metrics["overtime_days"], + } + + +def _refresh_secret_state( + state: dict[str, int | str], + definition: SecretSkillDefinition, + metrics: dict[str, object], +) -> None: + earned_rank = _candidate_rank( + _rank_metrics(metrics, definition), + definition.requirements, + ) + earned_rank_index = RANK_INDEX[earned_rank] + penalty_levels = min(int(state["penalty_levels"]), earned_rank_index) + state["penalty_levels"] = penalty_levels + state["earned_rank"] = earned_rank + state["effective_rank"] = RANK_CODES[max(earned_rank_index - penalty_levels, 0)] + + +def _advance_secret_state( + state: dict[str, int | str], + definition: SecretSkillDefinition, + metrics: dict[str, object], + *, + is_bad: bool, + record_date, +) -> None: + _refresh_secret_state(state, definition, metrics) + earned_rank_index = RANK_INDEX[str(state["earned_rank"])] + if earned_rank_index == 0: + state.update( + anti_streak=0, + recovery_streak=0, + penalty_levels=0, + effective_rank="none", + ) + return + + penalty_levels = int(state["penalty_levels"]) + if is_bad: + state["recovery_streak"] = 0 + if penalty_levels < earned_rank_index: + state["anti_streak"] = int(state["anti_streak"]) + 1 + if int(state["anti_streak"]) >= definition.anti_streak_limit: + state["penalty_levels"] = penalty_levels + 1 + state["anti_streak"] = 0 + state["last_demotion_date"] = record_date.isoformat() + else: + state["anti_streak"] = 0 + else: + state["anti_streak"] = 0 + if penalty_levels > 0: + state["recovery_streak"] = int(state["recovery_streak"]) + 1 + if int(state["recovery_streak"]) >= definition.recovery_streak_target: + state["penalty_levels"] = penalty_levels - 1 + state["recovery_streak"] = 0 + state["last_recovery_date"] = record_date.isoformat() + else: + state["recovery_streak"] = 0 + + _refresh_secret_state(state, definition, metrics) + + +def calculate_attendance_secret_metrics(records: Iterable) -> dict[str, object]: """Рассчитать фактические показатели по нормализованным записям.""" punctuality_streak = 0 @@ -110,6 +186,41 @@ def calculate_attendance_secret_metrics(records: Iterable) -> dict[str, int | st off_schedule_days = 0 overtime_days = 0 last_evaluated_date = "" + skill_states: dict[str, dict[str, int | str]] = { + definition.system_code: { + "earned_rank": "none", + "effective_rank": "none", + "anti_streak": 0, + "anti_streak_limit": definition.anti_streak_limit, + "recovery_streak": 0, + "recovery_streak_target": definition.recovery_streak_target, + "penalty_levels": 0, + "last_demotion_date": "", + "last_recovery_date": "", + } + for definition in SECRET_SKILL_DEFINITIONS + } + + def current_metrics(): + return { + "current_streak": punctuality_streak, + "best_streak": best_punctuality_streak, + "current_clean_streak": clean_streak, + "best_clean_streak": best_clean_streak, + "clean_days": clean_days, + "off_schedule_days": off_schedule_days, + "overtime_days": overtime_days, + "last_evaluated_date": last_evaluated_date, + } + + def refresh_paused_states(): + metrics = current_metrics() + for definition in SECRET_SKILL_DEFINITIONS: + _refresh_secret_state( + skill_states[definition.system_code], + definition, + metrics, + ) for record in sorted(records, key=lambda item: (item.date, item.id or 0)): if record.technical_issues: @@ -127,15 +238,26 @@ def calculate_attendance_secret_metrics(records: Iterable) -> dict[str, int | st and has_work ): off_schedule_days += 1 + refresh_paused_states() continue if record.personnel_status == ACTION_REMOTE: + refresh_paused_states() continue last_evaluated_date = record.date.isoformat() if record.is_absent or not has_work: punctuality_streak = 0 clean_streak = 0 + metrics = current_metrics() + for definition in SECRET_SKILL_DEFINITIONS: + _advance_secret_state( + skill_states[definition.system_code], + definition, + metrics, + is_bad=True, + record_date=record.date, + ) continue if record.is_late: @@ -162,6 +284,22 @@ def calculate_attendance_secret_metrics(records: Iterable) -> dict[str, int | st else: clean_streak = 0 + metrics = current_metrics() + for definition in SECRET_SKILL_DEFINITIONS: + is_bad = ( + bool(record.is_late) + if definition.progression_type + == Skill.ProgressionType.ATTENDANCE_PUNCTUALITY + else not is_clean + ) + _advance_secret_state( + skill_states[definition.system_code], + definition, + metrics, + is_bad=is_bad, + record_date=record.date, + ) + return { "current_streak": punctuality_streak, "best_streak": best_punctuality_streak, @@ -171,6 +309,7 @@ def calculate_attendance_secret_metrics(records: Iterable) -> dict[str, int | st "off_schedule_days": off_schedule_days, "overtime_days": overtime_days, "last_evaluated_date": last_evaluated_date, + "skill_states": skill_states, } @@ -204,13 +343,15 @@ def _definition_for_skill(skill: Skill) -> SecretSkillDefinition | None: def _metrics_for_definition( definition: SecretSkillDefinition, - metrics: dict[str, int | str], -) -> dict[str, int | str]: + metrics: dict[str, object], +) -> dict[str, object]: + skill_state = dict(metrics["skill_states"][definition.system_code]) if definition.progression_type == Skill.ProgressionType.ATTENDANCE_PUNCTUALITY: return { "current_streak": metrics["current_streak"], "best_streak": metrics["best_streak"], "last_evaluated_date": metrics["last_evaluated_date"], + **skill_state, } return { "current_clean_streak": metrics["current_clean_streak"], @@ -219,6 +360,7 @@ def _metrics_for_definition( "off_schedule_days": metrics["off_schedule_days"], "overtime_days": metrics["overtime_days"], "last_evaluated_date": metrics["last_evaluated_date"], + **skill_state, } @@ -254,35 +396,50 @@ def recalculate_attendance_secret_skills(employee_id: int) -> list[str]: continue definition_metrics = _metrics_for_definition(definition, metrics) - candidate_rank = _candidate_rank( - definition_metrics, - definition.requirements, - ) + calculated_rank = str(definition_metrics["effective_rank"]) progress = EmployeeSkillProgress.objects.filter( employee=employee, skill=skill, ).first() previous_rank = progress.special_rank if progress else "none" - achieved_rank = ( - candidate_rank - if RANK_INDEX[candidate_rank] > RANK_INDEX[previous_rank] - else previous_rank - ) - if achieved_rank == "none": - if progress and progress.is_active: + if calculated_rank == "none": + if progress: + was_active = progress.is_active + rank_history = dict( + progress.progress_data.get("rank_achieved_at") or {} + ) + rank_losses = list(progress.progress_data.get("rank_losses") or []) + if previous_rank != "none": + rank_losses.append( + { + "from": previous_rank, + "to": "none", + "at": now.isoformat(), + } + ) + progress.special_rank = "none" progress.is_active = False progress.experience = 0 - progress.progress_data = definition_metrics + progress.progress_data = { + **definition_metrics, + "rank_achieved_at": rank_history, + "rank_losses": rank_losses[-20:], + "recalculated_at": now.isoformat(), + } progress.save( update_fields=[ + "special_rank", "is_active", "experience", "progress_data", "updated_at", ] ) - employee.skills.remove(skill) + if was_active: + employee.skills.remove(skill) + if previous_rank != "none" or was_active: + changed_skills.append(skill.name) continue employee.skills.add(skill) @@ -291,16 +448,26 @@ def recalculate_attendance_secret_skills(employee_id: int) -> list[str]: skill=skill, ) rank_history = dict(progress.progress_data.get("rank_achieved_at") or {}) - if RANK_INDEX[achieved_rank] > RANK_INDEX[previous_rank]: - rank_history[achieved_rank] = now.isoformat() + if RANK_INDEX[calculated_rank] > RANK_INDEX[previous_rank]: + rank_history[calculated_rank] = now.isoformat() + rank_losses = list(progress.progress_data.get("rank_losses") or []) + if RANK_INDEX[calculated_rank] < RANK_INDEX[previous_rank]: + rank_losses.append( + { + "from": previous_rank, + "to": calculated_rank, + "at": now.isoformat(), + } + ) - progress.special_rank = achieved_rank + progress.special_rank = calculated_rank progress.experience = 0 progress.is_active = True progress.created_by = None progress.progress_data = { **definition_metrics, "rank_achieved_at": rank_history, + "rank_losses": rank_losses[-20:], "recalculated_at": now.isoformat(), } progress.save( @@ -316,7 +483,7 @@ def recalculate_attendance_secret_skills(employee_id: int) -> list[str]: progress.experience_events.filter(revoked_at__isnull=True).update( revoked_at=now, ) - if achieved_rank != previous_rank: + if calculated_rank != previous_rank: changed_skills.append(skill.name) return changed_skills @@ -334,22 +501,38 @@ def secret_skill_progress_payload( metrics = dict(progress.progress_data or {}) if progress else {} rank_index = RANK_INDEX.get(rank_code, 0) next_rank = RANK_CODES[rank_index + 1] if rank_index + 1 < len(RANK_CODES) else None - next_requirements = definition.requirements.get(next_rank or "", {}) - requirement_payload = [ - { - "key": metric, - "label": REQUIREMENT_LABELS[metric], - "current": int(metrics.get(metric, 0) or 0), - "target": target, - "complete": int(metrics.get(metric, 0) or 0) >= target, + penalty_levels = int(metrics.get("penalty_levels", 0) or 0) + if penalty_levels > 0 and next_rank: + next_requirements = { + "recovery_streak": definition.recovery_streak_target, } - for metric, target in next_requirements.items() - ] - if next_requirements: + requirement_payload = [ + { + "key": "recovery_streak", + "label": "Новая положительная серия", + "current": int(metrics.get("recovery_streak", 0) or 0), + "target": definition.recovery_streak_target, + "complete": int(metrics.get("recovery_streak", 0) or 0) + >= definition.recovery_streak_target, + } + ] + else: + next_requirements = definition.requirements.get(next_rank or "", {}) + requirement_payload = [ + { + "key": metric, + "label": REQUIREMENT_LABELS[metric], + "current": int(metrics.get(metric, 0) or 0), + "target": target, + "complete": int(metrics.get(metric, 0) or 0) >= target, + } + for metric, target in next_requirements.items() + ] + if requirement_payload: progress_percent = round( min( - min(int(metrics.get(metric, 0) or 0) / max(target, 1), 1) - for metric, target in next_requirements.items() + min(item["current"] / max(item["target"], 1), 1) + for item in requirement_payload ) * 100 ) @@ -358,12 +541,20 @@ def secret_skill_progress_payload( if definition.progression_type == Skill.ProgressionType.ATTENDANCE_PUNCTUALITY: progress_value = int(metrics.get("best_streak", 0) or 0) - progress_label = f"Лучшая серия: {progress_value} дн." + progress_label = ( + f"Восстановление ранга: {metrics.get('recovery_streak', 0)}/" + f"{definition.recovery_streak_target} дн." + if penalty_levels + else f"Лучшая серия: {progress_value} дн." + ) else: progress_value = int(metrics.get("clean_days", 0) or 0) completed = sum(item["complete"] for item in requirement_payload) progress_label = ( - f"До следующего ранга: {completed}/{len(requirement_payload)} условий" + f"Восстановление ранга: {metrics.get('recovery_streak', 0)}/" + f"{definition.recovery_streak_target} дн." + if penalty_levels + else f"До следующего ранга: {completed}/{len(requirement_payload)} условий" if requirement_payload else "Все условия выполнены" ) @@ -391,6 +582,15 @@ def secret_skill_progress_payload( "progress_label": progress_label, "progress_data": metrics, "next_rank_requirements": requirement_payload, + "anti_streak": int(metrics.get("anti_streak", 0) or 0), + "anti_streak_limit": definition.anti_streak_limit, + "anti_streak_remaining": max( + definition.anti_streak_limit - int(metrics.get("anti_streak", 0) or 0), + 0, + ), + "recovery_streak": int(metrics.get("recovery_streak", 0) or 0), + "recovery_streak_target": definition.recovery_streak_target, + "penalty_levels": penalty_levels, } diff --git a/backend/tests/api/v1/employees/test_skills.py b/backend/tests/api/v1/employees/test_skills.py index c570fb9e..ead084d7 100644 --- a/backend/tests/api/v1/employees/test_skills.py +++ b/backend/tests/api/v1/employees/test_skills.py @@ -140,6 +140,10 @@ def test_secret_skills_are_hidden_and_cannot_be_added_or_endorsed(api_client): assert progress_response.data["rank"] == "bronze" assert progress_response.data["rarity"] == "secret" assert progress_response.data["can_endorse"] is False + assert progress_response.data["anti_streak"] == 0 + assert progress_response.data["anti_streak_limit"] == 3 + assert progress_response.data["anti_streak_remaining"] == 3 + assert progress_response.data["recovery_streak_target"] == 20 assert progress_response.data["next_rank_requirements"] == [ { "key": "best_streak", diff --git a/backend/tests/employees/test_secret_skills.py b/backend/tests/employees/test_secret_skills.py index b705ea3d..4310a8bb 100644 --- a/backend/tests/employees/test_secret_skills.py +++ b/backend/tests/employees/test_secret_skills.py @@ -7,7 +7,6 @@ from employees.constants import ACTION_REMOTE from employees.models import EmployeeSkillProgress, Skill from employees.services.secret_skills import recalculate_attendance_secret_skills - pytestmark = pytest.mark.django_db @@ -86,14 +85,16 @@ def test_punctuality_uses_best_streak_and_pauses_unknown_days(user_factory): progress.refresh_from_db() assert progress.special_rank == "bronze" assert progress.progress_data["current_streak"] == 0 + assert progress.progress_data["anti_streak"] == 1 late_record.delete() assert recalculate_attendance_secret_skills(employee.id) == [] progress.refresh_from_db() assert progress.special_rank == "bronze" + assert progress.progress_data["anti_streak"] == 0 -def test_self_organization_requires_every_bronze_condition_and_never_demotes( +def test_self_organization_requires_every_bronze_condition( user_factory, ): employee = user_factory() @@ -139,6 +140,123 @@ def test_self_organization_requires_every_bronze_condition_and_never_demotes( recalculate_attendance_secret_skills(employee.id) progress.refresh_from_db() assert progress.progress_data["off_schedule_days"] == 4 + assert progress.special_rank == "none" + assert progress.is_active is False + assert not employee.skills.filter(pk=skill.pk).exists() + + +def test_punctuality_anti_streak_demotes_revokes_and_recovers(user_factory): + employee = user_factory() + start = date(2025, 1, 1) + run = _run(employee, start, start + timedelta(days=90)) + + for offset in range(45): + _record(run, employee, start + timedelta(days=offset)) + + recalculate_attendance_secret_skills(employee.id) + skill = Skill.objects.get(system_code="attendance_punctuality") + progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill) + assert progress.special_rank == "silver" + + for offset in range(45, 48): + _record( + run, + employee, + start + timedelta(days=offset), + is_late=True, + late_minutes=10, + ) + + recalculate_attendance_secret_skills(employee.id) + progress.refresh_from_db() + assert progress.special_rank == "bronze" + assert progress.progress_data["penalty_levels"] == 1 + assert progress.progress_data["anti_streak"] == 0 + + for offset in range(48, 51): + _record( + run, + employee, + start + timedelta(days=offset), + is_late=True, + late_minutes=10, + ) + + recalculate_attendance_secret_skills(employee.id) + progress.refresh_from_db() + assert progress.special_rank == "none" + assert progress.is_active is False + assert progress.progress_data["penalty_levels"] == 2 + assert not employee.skills.filter(pk=skill.pk).exists() + + for offset in range(51, 71): + _record(run, employee, start + timedelta(days=offset)) + + recalculate_attendance_secret_skills(employee.id) + progress.refresh_from_db() assert progress.special_rank == "bronze" assert progress.is_active is True + assert progress.progress_data["penalty_levels"] == 1 assert employee.skills.filter(pk=skill.pk).exists() + + for offset in range(71, 91): + _record(run, employee, start + timedelta(days=offset)) + + recalculate_attendance_secret_skills(employee.id) + progress.refresh_from_db() + assert progress.special_rank == "silver" + assert progress.progress_data["penalty_levels"] == 0 + + +def test_self_organization_anti_streak_revokes_and_recovers(user_factory): + employee = user_factory() + start = date(2024, 1, 1) + run = _run(employee, start, start + timedelta(days=99)) + + for offset in range(60): + _record( + run, + employee, + start + timedelta(days=offset), + is_overtime=offset < 5, + overtime_hours=1.5 if offset < 5 else None, + ) + for offset in range(60, 65): + _record( + run, + employee, + start + timedelta(days=offset), + is_workday=False, + effective_is_workday=False, + is_overtime=True, + overtime_hours=2, + ) + + recalculate_attendance_secret_skills(employee.id) + skill = Skill.objects.get(system_code="attendance_self_organization") + progress = EmployeeSkillProgress.objects.get(employee=employee, skill=skill) + assert progress.special_rank == "bronze" + + for offset in range(65, 70): + _record( + run, + employee, + start + timedelta(days=offset), + is_late=True, + late_minutes=10, + ) + + recalculate_attendance_secret_skills(employee.id) + progress.refresh_from_db() + assert progress.special_rank == "none" + assert progress.is_active is False + assert progress.progress_data["penalty_levels"] == 1 + + for offset in range(70, 100): + _record(run, employee, start + timedelta(days=offset)) + + recalculate_attendance_secret_skills(employee.id) + progress.refresh_from_db() + assert progress.special_rank == "bronze" + assert progress.is_active is True + assert progress.progress_data["penalty_levels"] == 0 diff --git a/frontend/src/components/users/ProfileSections.tsx b/frontend/src/components/users/ProfileSections.tsx index 5299b28b..b573deb9 100644 --- a/frontend/src/components/users/ProfileSections.tsx +++ b/frontend/src/components/users/ProfileSections.tsx @@ -876,7 +876,10 @@ export function ProfileSkillsInline({
`${skill.id}:${skill.rank}:${skill.experience}:${skill.rarity}`) + .map( + (skill) => + `${skill.id}:${skill.rank}:${skill.experience}:${skill.rarity}:${skill.anti_streak}:${skill.penalty_levels}`, + ) .join("|")} className="min-w-0 flex-1" ariaLabel="Навыки сотрудника" @@ -903,6 +906,22 @@ export function ProfileSkillsInline({ > {skill.rarity_display || "Не определена"} + {(skill.anti_streak || 0) > 0 ? ( + + ! {skill.anti_streak}/{skill.anti_streak_limit} + + ) : null} + {(skill.penalty_levels || 0) > 0 ? ( + + −{skill.penalty_levels} + + ) : null} )) ) : ( @@ -1146,6 +1165,29 @@ export function ProfileSkillsInline({ {progress.is_secret && progress.progress_label ? (

{progress.progress_label}

) : null} + {progress.is_secret && (progress.anti_streak || 0) > 0 ? ( +
+

+ Антисерия: {progress.anti_streak}/{progress.anti_streak_limit} +

+

+ {progress.anti_streak_remaining === 1 + ? "Следующее нарушение понизит ранг." + : `До понижения ранга: ${progress.anti_streak_remaining} наруш.`} +

+
+ ) : null} + {progress.is_secret && (progress.penalty_levels || 0) > 0 ? ( +
+

+ Ранг снижен на {progress.penalty_levels} ур. +

+

+ Восстановление: {progress.recovery_streak || 0}/ + {progress.recovery_streak_target} положительных дней. +

+
+ ) : null}
; next_rank_requirements?: SkillRankRequirement[]; + anti_streak?: number; + anti_streak_limit?: number; + anti_streak_remaining?: number; + recovery_streak?: number; + recovery_streak_target?: number; + penalty_levels?: number; } export type SkillRank = "none" | "bronze" | "silver" | "gold" | "ruby" | "diamond"; @@ -67,6 +73,12 @@ export interface EmployeeSkillProgress { progress_label?: string; progress_data?: Record; next_rank_requirements?: SkillRankRequirement[]; + anti_streak?: number; + anti_streak_limit?: number; + anti_streak_remaining?: number; + recovery_streak?: number; + recovery_streak_target?: number; + penalty_levels?: number; can_endorse: boolean; endorsement_points?: number; endorsement_role?: string;