feat: add secret skill anti-streaks

This commit is contained in:
lizerk 2026-08-26 09:21:34 +03:00
parent 2e807d83dd
commit 9d16efadec
7 changed files with 455 additions and 48 deletions

View File

@ -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(

View File

@ -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}"
)
)

View File

@ -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,
}

View File

@ -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",

View File

@ -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

View File

@ -876,7 +876,10 @@ export function ProfileSkillsInline({
<div className="flex min-w-0 items-center gap-2">
<HorizontalChipScroller
key={sortedSkills
.map((skill) => `${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 || "Не определена"}
</span>
{(skill.anti_streak || 0) > 0 ? (
<span
className="rounded-full bg-red-500/15 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-red-200"
title={`Антисерия ${skill.anti_streak}/${skill.anti_streak_limit}`}
>
! {skill.anti_streak}/{skill.anti_streak_limit}
</span>
) : null}
{(skill.penalty_levels || 0) > 0 ? (
<span
className="rounded-full bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-amber-200"
title={`Ранг снижен на ${skill.penalty_levels} уровней`}
>
−{skill.penalty_levels}
</span>
) : null}
</button>
))
) : (
@ -1146,6 +1165,29 @@ export function ProfileSkillsInline({
{progress.is_secret && progress.progress_label ? (
<p className="app-text-muted mt-3 text-xs">{progress.progress_label}</p>
) : null}
{progress.is_secret && (progress.anti_streak || 0) > 0 ? (
<div className="mt-3 rounded-xl border border-red-400/40 bg-red-500/10 px-3 py-2.5 text-xs text-red-200">
<p className="font-semibold">
Антисерия: {progress.anti_streak}/{progress.anti_streak_limit}
</p>
<p className="mt-1 text-red-200/80">
{progress.anti_streak_remaining === 1
? "Следующее нарушение понизит ранг."
: `До понижения ранга: ${progress.anti_streak_remaining} наруш.`}
</p>
</div>
) : null}
{progress.is_secret && (progress.penalty_levels || 0) > 0 ? (
<div className="mt-3 rounded-xl border border-amber-400/40 bg-amber-500/10 px-3 py-2.5 text-xs text-amber-200">
<p className="font-semibold">
Ранг снижен на {progress.penalty_levels} ур.
</p>
<p className="mt-1 text-amber-200/80">
Восстановление: {progress.recovery_streak || 0}/
{progress.recovery_streak_target} положительных дней.
</p>
</div>
) : null}
<div className="mt-4 h-2 overflow-hidden rounded-full bg-[var(--surface-primary)]">
<div
className="h-full rounded-full bg-[var(--accent-primary)] transition-[width]"

View File

@ -20,6 +20,12 @@ export interface Skill {
progress_label?: string;
progress_data?: Record<string, unknown>;
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<string, unknown>;
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;