Refine balance scoring and dashboard views

This commit is contained in:
2026-05-21 12:19:52 +02:00
parent 0fb8adbb14
commit abcd35714f
8 changed files with 316 additions and 48 deletions
+82 -29
View File
@@ -1547,6 +1547,7 @@ final class App
$isPersisted = isset($entryMap[$dayDate]);
$hasContent = $isPersisted || $this->entryHasContent($entry);
$visualScore = $this->dashboardVisualScore($entry, $isPersisted);
$lineLevel = $this->dashboardLineLevel($entry, $isPersisted);
$days[] = [
'date' => $dayDate,
@@ -1556,9 +1557,9 @@ final class App
'is_current' => $dayDate === $date,
'has_content' => $hasContent,
'visual_score' => $visualScore,
'score_level' => $visualScore,
'line_level' => $this->dashboardLineLevel($entry, $isPersisted),
'line_tone' => $visualScore === null ? 'empty' : signal_value_class($visualScore),
'score_level' => $lineLevel,
'line_level' => $lineLevel,
'line_tone' => $lineLevel === null ? 'empty' : signal_value_class($lineLevel),
];
}
@@ -1608,7 +1609,7 @@ final class App
$iso = $day->format('Y-m-d');
$entry = $entryMap[$iso] ?? null;
$hasContent = $entry !== null && $this->entryHasContent($entry);
$visualScore = $entry !== null ? $this->dashboardVisualScore($entry, true) : null;
$lineLevel = $entry !== null ? $this->dashboardLineLevel($entry, true) : null;
$days[] = [
'date' => $iso,
@@ -1617,8 +1618,8 @@ final class App
'day' => $day->format('j'),
'entry' => $entry,
'has_content' => $hasContent,
'score_level' => $visualScore,
'line_tone' => $visualScore === null ? 'empty' : signal_value_class($visualScore),
'score_level' => $lineLevel,
'line_tone' => $lineLevel === null ? 'empty' : signal_value_class($lineLevel),
'is_current' => $iso === $selectedDate,
];
}
@@ -1721,15 +1722,15 @@ final class App
$iso = $day->format('Y-m-d');
$entry = $entryMap[$iso] ?? null;
$hasContent = $entry !== null && $this->entryHasContent($entry);
$visualScore = $entry !== null ? $this->dashboardVisualScore($entry, true) : null;
$lineLevel = $entry !== null ? $this->dashboardLineLevel($entry, true) : null;
$days[] = [
'date' => $iso,
'day' => $day->format('j'),
'weekday' => format_display_date($iso, true),
'entry' => $entry,
'has_content' => $hasContent,
'score_level' => $visualScore,
'line_tone' => $visualScore === null ? 'empty' : signal_value_class($visualScore),
'score_level' => $lineLevel,
'line_tone' => $lineLevel === null ? 'empty' : signal_value_class($lineLevel),
'is_future' => $iso > $selectedDate,
];
}
@@ -1787,14 +1788,18 @@ final class App
return null;
}
if (is_array($entry['evaluation']['balance'] ?? null)) {
return max(-2, min(2, (int) ($entry['evaluation']['balance']['level'] ?? 0)));
}
$percentage = max(0.0, min(100.0, (float) ($entry['evaluation']['percentage'] ?? 0)));
return (int) round($percentage / 5);
return max(-2, min(2, (int) round(($percentage - 50.0) / 25.0)));
}
private function dashboardLineTone(array $entry, bool $isPersisted = false): string
{
return signal_value_class($this->dashboardVisualScore($entry, $isPersisted) ?? 0);
return signal_value_class($this->dashboardLineLevel($entry, $isPersisted) ?? 0);
}
private function dashboardEventFromPost(array $input): array
@@ -2555,6 +2560,10 @@ final class App
? round(array_sum(array_map(static fn (array $entry): float => (float) $entry['evaluation']['total'], $entries)) / $count, 1)
: 0.0;
$avgBalance = $count > 0
? round(array_sum(array_map(static fn (array $entry): float => (float) ($entry['evaluation']['balance']['raw'] ?? 0), $entries)) / $count, 1)
: 0.0;
$avgMood = $count > 0
? round(array_sum(array_map(static fn (array $entry): int => (int) $entry['mood'], $entries)) / $count, 1)
: 0.0;
@@ -2566,6 +2575,7 @@ final class App
return [
'tracked_days' => $count,
'average_score' => $avgScore,
'average_balance' => $avgBalance,
'average_mood' => $avgMood,
'average_stress' => $avgStress,
'streak' => $this->calculateStreak($entries),
@@ -2825,13 +2835,20 @@ final class App
return [
'calendar' => array_map(static function (array $entry): array {
$balance = is_array($entry['evaluation']['balance'] ?? null) ? $entry['evaluation']['balance'] : [];
return [
'date' => $entry['date'],
'score' => $entry['evaluation']['total'],
'max' => $entry['evaluation']['max_total'],
'score' => (float) ($balance['percentage'] ?? $entry['evaluation']['percentage'] ?? 0),
'max' => 100,
'label' => $entry['evaluation']['label'],
];
}, $calendar),
'balance' => array_map(static function (array $entry): array {
return [
'date' => $entry['date'],
'value' => (float) ($entry['evaluation']['balance']['raw'] ?? 0),
];
}, $recent),
'mood' => array_map(static function (array $entry): array {
return [
'date' => $entry['date'],
@@ -2915,13 +2932,24 @@ final class App
$settings['sleep'] = [
'optimal_hours' => max(1.0, min(16.0, round((float) ($input['sleep']['optimal_hours'] ?? ($settings['sleep']['optimal_hours'] ?? 7.0)), 1))),
];
$scoreMode = (string) ($input['display']['score_mode'] ?? ($settings['display']['score_mode'] ?? 'scale'));
$settings['display'] = [
'score_mode' => in_array($scoreMode, ['scale', 'percent', 'points'], true) ? $scoreMode : 'scale',
];
$settings['day_balance'] = [
'mood_weight' => max(0, min(10, (int) ($input['day_balance']['mood_weight'] ?? ($settings['day_balance']['mood_weight'] ?? 3)))),
'energy_weight' => max(0, min(10, (int) ($input['day_balance']['energy_weight'] ?? ($settings['day_balance']['energy_weight'] ?? 2)))),
'stress_weight' => max(0, min(10, (int) ($input['day_balance']['stress_weight'] ?? ($settings['day_balance']['stress_weight'] ?? 2)))),
'adjustment_cap' => max(0.0, min(2.0, round((float) ($input['day_balance']['adjustment_cap'] ?? ($settings['day_balance']['adjustment_cap'] ?? 1.0)), 1))),
'points_per_step' => max(1, min(50, (int) ($input['day_balance']['points_per_step'] ?? ($settings['day_balance']['points_per_step'] ?? 12)))),
];
$settings['scoring']['mood_multiplier'] = max(0, min(10, (int) ($input['scoring']['mood_multiplier'] ?? 3)));
$settings['scoring']['energy_multiplier'] = max(0, min(10, (int) ($input['scoring']['energy_multiplier'] ?? 2)));
$settings['scoring']['stress_multiplier'] = max(0, min(10, (int) ($input['scoring']['stress_multiplier'] ?? 2)));
$settings['scoring']['mood_multiplier'] = max(0, min(10, (int) ($input['scoring']['mood_multiplier'] ?? ($settings['scoring']['mood_multiplier'] ?? 3))));
$settings['scoring']['energy_multiplier'] = max(0, min(10, (int) ($input['scoring']['energy_multiplier'] ?? ($settings['scoring']['energy_multiplier'] ?? 2))));
$settings['scoring']['stress_multiplier'] = max(0, min(10, (int) ($input['scoring']['stress_multiplier'] ?? ($settings['scoring']['stress_multiplier'] ?? 2))));
$settings['scoring']['pain_multiplier'] = max(0, min(10, (int) ($input['scoring']['pain_multiplier'] ?? ($settings['scoring']['pain_multiplier'] ?? 3))));
$settings['scoring']['sleep_feeling_multiplier'] = max(0, min(10, (int) ($input['scoring']['sleep_feeling_multiplier'] ?? 2)));
$settings['scoring']['journal_points'] = max(0, min(20, (int) ($input['scoring']['journal_points'] ?? 2)));
$settings['scoring']['sleep_feeling_multiplier'] = max(0, min(10, (int) ($input['scoring']['sleep_feeling_multiplier'] ?? ($settings['scoring']['sleep_feeling_multiplier'] ?? 2))));
$settings['scoring']['journal_points'] = max(0, min(20, (int) ($input['scoring']['journal_points'] ?? ($settings['scoring']['journal_points'] ?? 2))));
$stepBonus = is_array($settings['scoring']['step_bonus'] ?? null) ? $settings['scoring']['step_bonus'] : $defaults['scoring']['step_bonus'];
$settings['scoring']['step_bonus'] = [
'min' => max(0, min(100000, (int) ($input['scoring']['step_bonus']['min'] ?? $stepBonus['min'] ?? 10000))),
@@ -2932,11 +2960,13 @@ final class App
$settings['scoring']['step_bonus']['max'] = $settings['scoring']['step_bonus']['min'];
}
$settings['tracking'] = [
'pain_enabled' => isset($input['tracking']['pain_enabled']) && $input['tracking']['pain_enabled'] === '1',
'pain_enabled' => array_key_exists('tracking', $input)
? (isset($input['tracking']['pain_enabled']) && $input['tracking']['pain_enabled'] === '1')
: !empty($settings['tracking']['pain_enabled']),
];
foreach ($defaults['scoring']['sleep_duration_points'] as $key => $default) {
$settings['scoring']['sleep_duration_points'][$key] = max(0, min(20, (int) ($input['scoring']['sleep_duration_points'][$key] ?? $default)));
$settings['scoring']['sleep_duration_points'][$key] = max(0, min(20, (int) ($input['scoring']['sleep_duration_points'][$key] ?? ($settings['scoring']['sleep_duration_points'][$key] ?? $default))));
}
foreach (['sport_bands', 'walk_bands'] as $bandKey) {
@@ -2946,25 +2976,35 @@ final class App
$settings['scoring'][$bandKey][$index] = [
'min' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['min'] ?? $currentBand['min'] ?? $defaultBand['min']))),
'max' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['max'] ?? $currentBand['max'] ?? $defaultBand['max']))),
'points' => max(0, min(20, (int) ($input['scoring'][$bandKey][$index]['points'] ?? $currentBand['points'] ?? $defaultBand['points']))),
'points' => max(-20, min(20, (int) ($input['scoring'][$bandKey][$index]['points'] ?? $currentBand['points'] ?? $defaultBand['points']))),
];
}
}
foreach ($defaults['scoring']['walk_step_targets'] as $index => $defaultTarget) {
$currentTarget = $settings['scoring']['walk_step_targets'][$index] ?? $defaultTarget;
$settings['scoring']['walk_step_targets'][$index] = [
'steps' => max(0, min(100000, (int) ($input['scoring']['walk_step_targets'][$index]['steps'] ?? $currentTarget['steps'] ?? $defaultTarget['steps']))),
'points' => max(-20, min(20, (int) ($input['scoring']['walk_step_targets'][$index]['points'] ?? $currentTarget['points'] ?? $defaultTarget['points']))),
];
}
foreach ($defaults['ratings'] as $index => $defaultRating) {
$currentRating = $settings['ratings'][$index] ?? $defaultRating;
$settings['ratings'][$index] = [
'label' => trim((string) ($input['ratings'][$index]['label'] ?? $defaultRating['label'])) ?: $defaultRating['label'],
'min' => max(0, min(999, (int) ($input['ratings'][$index]['min'] ?? $defaultRating['min']))),
'max' => max(0, min(999, (int) ($input['ratings'][$index]['max'] ?? $defaultRating['max']))),
'label' => trim((string) ($input['ratings'][$index]['label'] ?? $currentRating['label'] ?? $defaultRating['label'])) ?: $defaultRating['label'],
'min' => max(0, min(999, (int) ($input['ratings'][$index]['min'] ?? $currentRating['min'] ?? $defaultRating['min']))),
'max' => max(0, min(999, (int) ($input['ratings'][$index]['max'] ?? $currentRating['max'] ?? $defaultRating['max']))),
];
}
foreach ($defaults['guardrails'] as $index => $defaultGuardrail) {
$energyRaw = $input['guardrails'][$index]['energy_max'] ?? $defaultGuardrail['energy_max'];
$currentGuardrail = $settings['guardrails'][$index] ?? $defaultGuardrail;
$energyRaw = $input['guardrails'][$index]['energy_max'] ?? $currentGuardrail['energy_max'] ?? $defaultGuardrail['energy_max'];
$settings['guardrails'][$index] = [
'mood_max' => max(1, min(10, (int) ($input['guardrails'][$index]['mood_max'] ?? $defaultGuardrail['mood_max']))),
'mood_max' => max(1, min(10, (int) ($input['guardrails'][$index]['mood_max'] ?? $currentGuardrail['mood_max'] ?? $defaultGuardrail['mood_max']))),
'energy_max' => $energyRaw === '' || $energyRaw === null ? null : max(1, min(10, (int) $energyRaw)),
'cap_label' => trim((string) ($input['guardrails'][$index]['cap_label'] ?? $defaultGuardrail['cap_label'])) ?: $defaultGuardrail['cap_label'],
'cap_label' => trim((string) ($input['guardrails'][$index]['cap_label'] ?? $currentGuardrail['cap_label'] ?? $defaultGuardrail['cap_label'])) ?: $defaultGuardrail['cap_label'],
];
}
@@ -2974,7 +3014,7 @@ final class App
$settings['sport_types'] = normalized_sport_types([
'sport_types' => $sportTypesProvided && is_array($input['sport_types'] ?? null)
? $input['sport_types']
: ($sportTypesProvided ? [] : $defaults['sport_types']),
: ($sportTypesProvided ? [] : ($settings['sport_types'] ?? $defaults['sport_types'])),
]);
$time = trim((string) ($input['notifications']['time'] ?? ($settings['notifications']['time'] ?? $defaults['notifications']['time'])));
@@ -2983,7 +3023,9 @@ final class App
}
$settings['notifications'] = [
'enabled' => isset($input['notifications']['enabled']) && $input['notifications']['enabled'] === '1',
'enabled' => array_key_exists('notifications', $input)
? (isset($input['notifications']['enabled']) && $input['notifications']['enabled'] === '1')
: !empty($settings['notifications']['enabled']),
'time' => $time,
];
@@ -3002,6 +3044,17 @@ final class App
is_array($settings['sleep'] ?? null) ? $settings['sleep'] : []
);
$settings['sleep']['optimal_hours'] = max(1.0, min(16.0, round((float) ($settings['sleep']['optimal_hours'] ?? 7.0), 1)));
$settings['display'] = array_replace(
Defaults::settings()['display'],
is_array($settings['display'] ?? null) ? $settings['display'] : []
);
if (!in_array((string) ($settings['display']['score_mode'] ?? 'scale'), ['scale', 'percent', 'points'], true)) {
$settings['display']['score_mode'] = 'scale';
}
$settings['day_balance'] = array_replace(
Defaults::settings()['day_balance'],
is_array($settings['day_balance'] ?? null) ? $settings['day_balance'] : []
);
$settings['tracking'] = array_replace(
Defaults::settings()['tracking'],
is_array($settings['tracking'] ?? null) ? $settings['tracking'] : []
+57 -1
View File
@@ -71,8 +71,9 @@ final class ScoringService
'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' => 0.0,
'walk_minutes' => $this->walkPoints($entry, $settings),
'step_bonus' => $this->stepBonusPoints($entry, $scoring['step_bonus'] ?? []),
'daily_steps' => $this->stepTargetPoints((int) ($entry['health']['steps'] ?? 0), $scoring['walk_step_targets'] ?? []),
'events' => $eventSignalPoints,
'alcohol' => !empty($entry['alcohol']) ? ((float) abs((float) ($scoring['alcohol_penalty'] ?? 5)) * -1) : 0.0,
'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'],
@@ -92,6 +93,8 @@ final class ScoringService
(5 * (float) $scoring['sleep_feeling_multiplier']) +
$this->maxBandPoints($scoring['sport_bands']) +
$this->maxSportBonusPoints($settings) +
$this->maxWalkPoints($entry, $settings) +
$this->maxStepTargetPoints($scoring['walk_step_targets'] ?? []) +
max(0.0, (float) ($scoring['step_bonus']['points'] ?? 0)) +
($eventSignalPoints !== 0.0 ? 8.0 : 0.0) +
(float) $scoring['journal_points'],
@@ -123,11 +126,50 @@ final class ScoringService
'guardrail' => $guardrail,
'sentiment' => $this->sentimentForLabel($label, $ratings),
'percentage' => $maxTotal > 0 ? round(($total / $maxTotal) * 100, 1) : 0,
'balance' => $this->dayBalance($entry, $components, $settings),
'sport_type' => $sportTypes[0] ?? null,
'sport_types' => $sportTypes,
];
}
private function dayBalance(array $entry, array $components, array $settings): array
{
$config = is_array($settings['day_balance'] ?? null) ? $settings['day_balance'] : [];
$moodWeight = max(0.0, (float) ($config['mood_weight'] ?? 3));
$energyWeight = max(0.0, (float) ($config['energy_weight'] ?? 2));
$stressWeight = max(0.0, (float) ($config['stress_weight'] ?? 2));
$weightTotal = max(1.0, $moodWeight + $energyWeight + $stressWeight);
$summary = is_array($entry['summary'] ?? null) ? $entry['summary'] : [];
$mood = normalize_signal_value($summary['mood'] ?? $entry['summary_mood'] ?? legacy_to_signal_scale($entry['mood'] ?? 5));
$energy = normalize_signal_value($summary['energy'] ?? $entry['summary_energy'] ?? legacy_to_signal_scale($entry['energy'] ?? 5));
$stress = normalize_signal_value($summary['stress'] ?? $entry['summary_stress'] ?? legacy_to_signal_scale($entry['stress'] ?? 5));
$base = (($mood * $moodWeight) + ($energy * $energyWeight) + ((-$stress) * $stressWeight)) / $weightTotal;
$adjustmentPoints = 0.0;
foreach ($components as $key => $value) {
if (in_array($key, ['mood', 'energy', 'stress', 'pain'], true)) {
continue;
}
$adjustmentPoints += (float) $value;
}
$pointsPerStep = max(1.0, (float) ($config['points_per_step'] ?? 12));
$cap = max(0.0, min(2.0, (float) ($config['adjustment_cap'] ?? 1.0)));
$adjustment = max(-$cap, min($cap, $adjustmentPoints / $pointsPerStep));
$raw = max(-2.0, min(2.0, $base + $adjustment));
$level = max(-2, min(2, (int) round($raw)));
return [
'base' => round($base, 2),
'adjustment' => round($adjustment, 2),
'raw' => round($raw, 2),
'level' => $level,
'percentage' => round((($raw + 2.0) / 4.0) * 100, 1),
'tone' => signal_value_class($level),
];
}
private function eventSignalPoints(array $events): float
{
if ($events === []) {
@@ -249,6 +291,20 @@ final class ScoringService
return $this->maxBandPoints($scoring['walk_bands'] ?? []);
}
private function maxStepTargetPoints(array $targets): float
{
$max = 0.0;
foreach ($targets as $target) {
if (!is_array($target)) {
continue;
}
$max = max($max, (float) ($target['points'] ?? 0));
}
return $max;
}
private function stepTargetPoints(int $steps, array $targets): float
{
if ($targets === []) {
+10
View File
@@ -22,6 +22,16 @@ final class Defaults
'sleep' => [
'optimal_hours' => 7.0,
],
'display' => [
'score_mode' => 'scale',
],
'day_balance' => [
'mood_weight' => 3,
'energy_weight' => 2,
'stress_weight' => 2,
'adjustment_cap' => 1.0,
'points_per_step' => 12,
],
'tracking' => [
'pain_enabled' => false,
],