284 lines
9.8 KiB
PHP
284 lines
9.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
final class ScoringService
|
|
{
|
|
public function normalize(array $input): array
|
|
{
|
|
$sportTypes = normalize_sport_type_selection($input['sport_types'] ?? ($input['sport_type'] ?? []));
|
|
|
|
return [
|
|
'date' => $input['date'] ?? today(),
|
|
'mood' => max(1, min(10, (int) ($input['mood'] ?? 5))),
|
|
'energy' => max(1, min(10, (int) ($input['energy'] ?? 5))),
|
|
'stress' => max(1, min(10, (int) ($input['stress'] ?? 5))),
|
|
'sleep_hours' => max(0, min(24, (float) ($input['sleep_hours'] ?? 0))),
|
|
'sleep_feeling' => max(1, min(5, (int) ($input['sleep_feeling'] ?? 3))),
|
|
'sport_minutes' => max(0, min(1440, (int) ($input['sport_minutes'] ?? 0))),
|
|
'sport_type' => $sportTypes[0] ?? '',
|
|
'sport_types' => $sportTypes,
|
|
'walk_minutes' => max(0, min(1440, (int) ($input['walk_minutes'] ?? 0))),
|
|
'note' => trim((string) ($input['note'] ?? '')),
|
|
];
|
|
}
|
|
|
|
public function evaluate(array $entry, array $settings, ?array $previousEntry = null): array
|
|
{
|
|
$entry = $this->normalize($entry);
|
|
$previousEntry = $previousEntry !== null ? $this->normalize($previousEntry) : null;
|
|
$scoring = $settings['scoring'];
|
|
$ratings = $this->sortedRatings($settings['ratings'] ?? []);
|
|
$sportTypes = find_sport_types($settings, $entry['sport_types']);
|
|
$sportBonus = $this->sportBonusPoints($entry, $previousEntry, $settings, $sportTypes);
|
|
|
|
$components = [
|
|
'mood' => $entry['mood'] * (float) $scoring['mood_multiplier'],
|
|
'energy' => $entry['energy'] * (float) $scoring['energy_multiplier'],
|
|
'stress' => (11 - $entry['stress']) * (float) $scoring['stress_multiplier'],
|
|
'sleep_hours' => $this->sleepDurationPoints((float) $entry['sleep_hours'], $scoring['sleep_duration_points']),
|
|
'sleep_feeling' => $entry['sleep_feeling'] * (float) $scoring['sleep_feeling_multiplier'],
|
|
'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']),
|
|
'sport_bonus' => $sportBonus,
|
|
'walk_minutes' => $this->bandPoints((int) $entry['walk_minutes'], $scoring['walk_bands']),
|
|
'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'],
|
|
];
|
|
|
|
$total = round(array_sum($components), 1);
|
|
$maxTotal = round(
|
|
(10 * (float) $scoring['mood_multiplier']) +
|
|
(10 * (float) $scoring['energy_multiplier']) +
|
|
(10 * (float) $scoring['stress_multiplier']) +
|
|
max(array_map('floatval', $scoring['sleep_duration_points'])) +
|
|
(5 * (float) $scoring['sleep_feeling_multiplier']) +
|
|
$this->maxBandPoints($scoring['sport_bands']) +
|
|
$this->maxSportBonusPoints($settings) +
|
|
$this->maxBandPoints($scoring['walk_bands']) +
|
|
(float) $scoring['journal_points'],
|
|
1
|
|
);
|
|
|
|
$label = $this->labelForScore($total, $ratings);
|
|
$guardrail = null;
|
|
|
|
foreach ($settings['guardrails'] ?? [] as $rule) {
|
|
$moodMatch = $entry['mood'] <= (int) ($rule['mood_max'] ?? 10);
|
|
$energyLimit = $rule['energy_max'] ?? null;
|
|
$energyMatch = $energyLimit === null || $entry['energy'] <= (int) $energyLimit;
|
|
|
|
if ($moodMatch && $energyMatch) {
|
|
$capped = $this->capLabel($label, (string) ($rule['cap_label'] ?? $label), $ratings);
|
|
if ($capped !== $label) {
|
|
$guardrail = (string) ($rule['cap_label'] ?? '');
|
|
$label = $capped;
|
|
}
|
|
}
|
|
}
|
|
|
|
return [
|
|
'components' => $components,
|
|
'total' => $total,
|
|
'max_total' => $maxTotal,
|
|
'label' => $label,
|
|
'guardrail' => $guardrail,
|
|
'sentiment' => $this->sentimentForLabel($label, $ratings),
|
|
'percentage' => $maxTotal > 0 ? round(($total / $maxTotal) * 100, 1) : 0,
|
|
'sport_type' => $sportTypes[0] ?? null,
|
|
'sport_types' => $sportTypes,
|
|
];
|
|
}
|
|
|
|
private function sleepDurationPoints(float $hours, array $points): float
|
|
{
|
|
if ($hours < 4) {
|
|
return (float) ($points['lt4'] ?? 0);
|
|
}
|
|
|
|
if ($hours >= 10) {
|
|
return (float) ($points['h10plus'] ?? 0);
|
|
}
|
|
|
|
$anchors = [
|
|
4.0 => (float) ($points['h4'] ?? 0),
|
|
5.0 => (float) ($points['h5'] ?? 0),
|
|
6.0 => (float) ($points['h6'] ?? 0),
|
|
7.0 => (float) ($points['h7'] ?? 0),
|
|
8.0 => (float) ($points['h8'] ?? 0),
|
|
9.0 => (float) ($points['h9'] ?? 0),
|
|
10.0 => (float) ($points['h10plus'] ?? 0),
|
|
];
|
|
|
|
$lowerHour = floor($hours);
|
|
$upperHour = ceil($hours);
|
|
|
|
if ($lowerHour === $upperHour) {
|
|
return (float) ($anchors[(float) $lowerHour] ?? 0);
|
|
}
|
|
|
|
$lowerPoints = $anchors[(float) $lowerHour] ?? 0.0;
|
|
$upperPoints = $anchors[(float) $upperHour] ?? 0.0;
|
|
$fraction = $hours - $lowerHour;
|
|
|
|
return round($lowerPoints + (($upperPoints - $lowerPoints) * $fraction), 1);
|
|
}
|
|
|
|
private function bandPoints(int $value, array $bands): float
|
|
{
|
|
foreach ($bands as $band) {
|
|
if ($value >= (int) ($band['min'] ?? 0) && $value <= (int) ($band['max'] ?? 0)) {
|
|
return (float) ($band['points'] ?? 0);
|
|
}
|
|
}
|
|
|
|
$last = end($bands);
|
|
|
|
return (float) ($last['points'] ?? 0);
|
|
}
|
|
|
|
private function maxBandPoints(array $bands): float
|
|
{
|
|
$max = 0.0;
|
|
|
|
foreach ($bands as $band) {
|
|
$max = max($max, (float) ($band['points'] ?? 0));
|
|
}
|
|
|
|
return $max;
|
|
}
|
|
|
|
private function maxSportBonusPoints(array $settings): float
|
|
{
|
|
$max = 0.0;
|
|
$perGroup = [];
|
|
|
|
foreach (normalized_sport_types($settings) as $type) {
|
|
$bonus = (float) ($type['bonus_points'] ?? 0);
|
|
if ($bonus <= 0) {
|
|
continue;
|
|
}
|
|
|
|
if (!empty($type['allow_consecutive'])) {
|
|
$max += $bonus;
|
|
continue;
|
|
}
|
|
|
|
$group = (string) ($type['recovery_group'] ?? $type['id'] ?? '');
|
|
if ($group === '') {
|
|
$group = (string) ($type['id'] ?? '');
|
|
}
|
|
|
|
$perGroup[$group] = max((float) ($perGroup[$group] ?? 0), $bonus);
|
|
}
|
|
|
|
foreach ($perGroup as $bonus) {
|
|
$max += $bonus;
|
|
}
|
|
|
|
return $max;
|
|
}
|
|
|
|
private function sportBonusPoints(array $entry, ?array $previousEntry, array $settings, array $currentSportTypes): float
|
|
{
|
|
if ((int) $entry['sport_minutes'] <= 0 || $currentSportTypes === []) {
|
|
return 0.0;
|
|
}
|
|
|
|
$previousGroups = [];
|
|
if ($previousEntry !== null && (int) $previousEntry['sport_minutes'] > 0) {
|
|
foreach (find_sport_types($settings, $previousEntry['sport_types']) as $type) {
|
|
$group = (string) ($type['recovery_group'] ?? $type['id'] ?? '');
|
|
if ($group !== '') {
|
|
$previousGroups[$group] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
$groupBonuses = [];
|
|
$total = 0.0;
|
|
|
|
foreach ($currentSportTypes as $type) {
|
|
$bonus = (float) ($type['bonus_points'] ?? 0);
|
|
if ($bonus <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$group = (string) ($type['recovery_group'] ?? $type['id'] ?? '');
|
|
if ($group === '') {
|
|
$group = (string) ($type['id'] ?? '');
|
|
}
|
|
|
|
if (!empty($type['allow_consecutive'])) {
|
|
$total += $bonus;
|
|
continue;
|
|
}
|
|
|
|
if (isset($previousGroups[$group])) {
|
|
continue;
|
|
}
|
|
|
|
$groupBonuses[$group] = max((float) ($groupBonuses[$group] ?? 0), $bonus);
|
|
}
|
|
|
|
foreach ($groupBonuses as $bonus) {
|
|
$total += $bonus;
|
|
}
|
|
|
|
return $total;
|
|
}
|
|
|
|
private function sortedRatings(array $ratings): array
|
|
{
|
|
usort($ratings, static fn (array $a, array $b): int => ((int) $a['min']) <=> ((int) $b['min']));
|
|
|
|
return $ratings;
|
|
}
|
|
|
|
private function labelForScore(float $score, array $ratings): string
|
|
{
|
|
foreach ($ratings as $rating) {
|
|
if ($score >= (float) $rating['min'] && $score <= (float) $rating['max']) {
|
|
return (string) $rating['label'];
|
|
}
|
|
}
|
|
|
|
if ($score < (float) ($ratings[0]['min'] ?? 0)) {
|
|
return (string) ($ratings[0]['label'] ?? 'unbewertet');
|
|
}
|
|
|
|
return (string) ($ratings[count($ratings) - 1]['label'] ?? 'unbewertet');
|
|
}
|
|
|
|
private function capLabel(string $current, string $cap, array $ratings): string
|
|
{
|
|
$order = array_map(static fn (array $rating): string => (string) $rating['label'], $ratings);
|
|
$currentIndex = array_search($current, $order, true);
|
|
$capIndex = array_search($cap, $order, true);
|
|
|
|
if ($currentIndex === false || $capIndex === false) {
|
|
return $current;
|
|
}
|
|
|
|
return $currentIndex > $capIndex ? $cap : $current;
|
|
}
|
|
|
|
private function sentimentForLabel(string $label, array $ratings): string
|
|
{
|
|
$order = array_values(array_map(static fn (array $rating): string => (string) $rating['label'], $ratings));
|
|
$index = array_search($label, $order, true);
|
|
|
|
if ($index === false || count($order) <= 1) {
|
|
return 'steady';
|
|
}
|
|
|
|
$ratio = $index / max(count($order) - 1, 1);
|
|
|
|
return match (true) {
|
|
$ratio <= 0.1 => 'storm',
|
|
$ratio <= 0.35 => 'heavy',
|
|
$ratio <= 0.65 => 'steady',
|
|
$ratio <= 0.9 => 'bright',
|
|
default => 'radiant',
|
|
};
|
|
}
|
|
}
|