diff --git a/assets/css/app.css b/assets/css/app.css index f0bf3c4..df80d21 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -547,6 +547,34 @@ body.page-dashboard .content { .compare-day__line.score-2 .compare-day__marker { top: 12%; } .compare-day__line.score-empty .compare-day__marker { top: 50%; } +.score-scale { + position: absolute; + inset: 0 auto 0 0; + display: flex; + flex-direction: column; + justify-content: space-between; + width: 2.2rem; + padding-block: 0.25rem; + color: rgba(239, 247, 255, 0.52); + font-size: 0.66rem; + line-height: 1; + pointer-events: none; +} + +.score-scale--day { + left: calc(50% - 9.8rem); +} + +.score-scale--range { + left: 0.35rem; + top: 1rem; + bottom: 2rem; +} + +.score-scale--month { + bottom: 1rem; +} + .tone-neg2 { background: #ff8f8f !important; border-color: rgba(255, 143, 143, 0.6) !important; } .tone-neg1 { background: #ffbf8d !important; border-color: rgba(255, 191, 141, 0.6) !important; } .tone-zero { background: #8be4ff !important; border-color: rgba(139, 228, 255, 0.6) !important; } @@ -1559,6 +1587,7 @@ body.page-dashboard .content { } .range-score-strip { + position: relative; display: grid; gap: 0.55rem; align-items: stretch; @@ -1569,13 +1598,14 @@ body.page-dashboard .content { .range-score-strip--week { grid-template-columns: repeat(7, minmax(0, 1fr)); + padding-left: 2.45rem; } .range-score-strip--month { display: flex; gap: 0.18rem; overflow: visible; - padding-inline: 0.8rem; + padding-inline: 2.45rem 0.8rem; } .range-score-day { @@ -1635,11 +1665,15 @@ body.page-dashboard .content { display: flex; flex-direction: column; gap: 1rem; - max-height: min(68vh, 56rem); - overflow: auto; + overflow: visible; padding-right: 0.15rem; } +.range-day-list--month { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); +} + .range-day-card { display: block; overflow: hidden; @@ -1713,6 +1747,18 @@ body.page-dashboard .content { line-height: 1.45; } +.range-day-card__score { + display: inline-flex; + width: fit-content; + margin: 0 0 0.5rem; + padding: 0.34rem 0.62rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.1); + color: rgba(239, 247, 255, 0.82); + font-size: 0.82rem; + font-weight: 700; +} + .range-day-card--summary-only .range-day-card__summary { font-size: clamp(1rem, 2.2vw, 1.25rem); } @@ -1739,6 +1785,15 @@ body.page-dashboard .content { display: block; } +.signal-scale { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + width: min(100%, 13rem); + color: rgba(239, 247, 255, 0.52); + font-size: 0.68rem; + text-align: center; +} + .range-moment-list__item span span { margin-top: 0.16rem; color: rgba(239, 247, 255, 0.64); @@ -2248,6 +2303,15 @@ body.page-dashboard .content { padding: 0.95rem; } + .range-score-strip--week, + .range-score-strip--month { + padding-left: 2.35rem; + } + + .range-day-list--month { + grid-template-columns: 1fr; + } + .dashboard-topbar { position: relative; top: auto; diff --git a/assets/js/app.js b/assets/js/app.js index a7eec20..7c8ebbf 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -425,7 +425,10 @@ let minValue = Math.min(...values); let maxValue = Math.max(...values); - if (seriesName === "mood" || seriesName === "stress" || seriesName === "pain") { + if (seriesName === "balance") { + minValue = -2; + maxValue = 2; + } else if (seriesName === "mood" || seriesName === "stress" || seriesName === "pain") { minValue = Math.max(1, minValue - 1.5); maxValue = Math.min(10, maxValue + 1.5); } else { @@ -435,7 +438,10 @@ if ((maxValue - minValue) < 3) { const center = (maxValue + minValue) / 2; - if (seriesName === "mood" || seriesName === "stress" || seriesName === "pain") { + if (seriesName === "balance") { + minValue = -2; + maxValue = 2; + } else if (seriesName === "mood" || seriesName === "stress" || seriesName === "pain") { minValue = Math.max(1, center - 1.5); maxValue = Math.min(10, center + 1.5); } else { @@ -701,7 +707,7 @@ } const title = item.entry - ? `${item.date}: ${formatNumber(Number(item.entry.score))} Punkte · ${item.entry.label}` + ? `${item.date}: ${formatNumber(Number(item.entry.score))}% Bilanz · ${item.entry.label}` : `${item.date}: kein Eintrag`; if (!item.entry) { @@ -732,7 +738,7 @@
${formatNumber(Number(latestVisibleEntry.score))} - Punkte + % Bilanz
Tag öffnen `; @@ -1070,6 +1076,12 @@ return "tone-zero"; }; + const signalLabels = { + mood: { "-2": "sehr niedrig", "-1": "niedrig", 0: "neutral", 1: "hoch", 2: "sehr hoch" }, + energy: { "-2": "leer", "-1": "matt", 0: "okay", 1: "wach", 2: "kraftvoll" }, + stress: { "-2": "sehr ruhig", "-1": "ruhig", 0: "neutral", 1: "angespannt", 2: "sehr angespannt" }, + }; + const setHidden = (element, hidden) => { if (!element) { return; @@ -1113,6 +1125,10 @@ const metric = stepper.dataset.stepperMetric || "mood"; input.value = String(current); value.textContent = `${current > 0 ? "+" : ""}${current}`; + const label = stepper.querySelector("[data-stepper-label]"); + if (label) { + label.textContent = signalLabels[metric]?.[current] || signalLabels.mood[current] || "neutral"; + } minus.disabled = current <= -2; plus.disabled = current >= 2; const ring = stepper.querySelector(".overlay-signal-card__ring"); @@ -1434,20 +1450,22 @@ let pointerStartX = 0; let pointerStartY = 0; let dragging = false; + let ignoreSwipe = false; const handleSwipe = (deltaX, deltaY) => { - if (Math.abs(deltaX) < 70 || Math.abs(deltaY) > 50) { + if (ignoreSwipe || document.body.classList.contains("is-dashboard-overlay-open") || Math.abs(deltaX) < 70 || Math.abs(deltaY) > 50) { return; } - if (deltaX < 0 && swipeContainer.dataset.prevDate) { - window.location.href = dashboardDayPath(swipeContainer.dataset.prevDate); - } else if (deltaX > 0 && swipeContainer.dataset.nextDate) { + if (deltaX < 0 && swipeContainer.dataset.nextDate) { window.location.href = dashboardDayPath(swipeContainer.dataset.nextDate); + } else if (deltaX > 0 && swipeContainer.dataset.prevDate) { + window.location.href = dashboardDayPath(swipeContainer.dataset.prevDate); } }; swipeContainer.addEventListener("pointerdown", event => { + ignoreSwipe = Boolean(event.target.closest("a, button, input, textarea, select, label, [data-event-editable]")); dragging = true; pointerStartX = event.clientX; pointerStartY = event.clientY; @@ -1471,12 +1489,12 @@ return; } - if (event.key === "ArrowLeft" && swipeContainer.dataset.prevDate) { - window.location.href = dashboardDayPath(swipeContainer.dataset.prevDate); + if (event.key === "ArrowLeft" && swipeContainer.dataset.nextDate) { + window.location.href = dashboardDayPath(swipeContainer.dataset.nextDate); } - if (event.key === "ArrowRight" && swipeContainer.dataset.nextDate) { - window.location.href = dashboardDayPath(swipeContainer.dataset.nextDate); + if (event.key === "ArrowRight" && swipeContainer.dataset.prevDate) { + window.location.href = dashboardDayPath(swipeContainer.dataset.prevDate); } }); } diff --git a/src/App.php b/src/App.php index 28e7e26..1cd56b8 100644 --- a/src/App.php +++ b/src/App.php @@ -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'] : [] diff --git a/src/Domain/ScoringService.php b/src/Domain/ScoringService.php index 166f93f..97d3f76 100644 --- a/src/Domain/ScoringService.php +++ b/src/Domain/ScoringService.php @@ -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 === []) { diff --git a/src/Support/Defaults.php b/src/Support/Defaults.php index fac2169..c66b306 100644 --- a/src/Support/Defaults.php +++ b/src/Support/Defaults.php @@ -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, ], diff --git a/templates/layout.php b/templates/layout.php index b4a7e89..8939aa0 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -116,7 +116,7 @@ $jsVersion = is_file(base_path('assets/js/app.js')) ? (string) filemtime(base_pa diff --git a/templates/pages/dashboard.php b/templates/pages/dashboard.php index 4b954b2..e1c4931 100644 --- a/templates/pages/dashboard.php +++ b/templates/pages/dashboard.php @@ -11,6 +11,24 @@ $dayHealth = is_array($dayEntry['health'] ?? null) ? $dayEntry['health'] : []; $daySteps = (int) ($dayHealth['steps'] ?? 0); $dayStepBonus = (float) ($dayEntry['evaluation']['components']['step_bonus'] ?? 0); $optimalSleepHours = max(1.0, min(16.0, (float) ($settings['sleep']['optimal_hours'] ?? 7.0))); +$formatBalanceValue = static function (?array $entry) use ($settings): string { + if ($entry === null) { + return ''; + } + + $mode = (string) ($settings['display']['score_mode'] ?? 'scale'); + $balance = is_array($entry['evaluation']['balance'] ?? null) ? $entry['evaluation']['balance'] : []; + if ($mode === 'points') { + return format_points((float) ($entry['evaluation']['total'] ?? 0)) . ' Punkte'; + } + + if ($mode === 'percent') { + return format_points((float) ($balance['percentage'] ?? $entry['evaluation']['percentage'] ?? 0)) . ' %'; + } + + $level = max(-2, min(2, (int) ($balance['level'] ?? 0))); + return ($level > 0 ? '+' : '') . (string) $level; +}; ?>
@@ -39,6 +57,7 @@ $optimalSleepHours = max(1.0, min(16.0, (float) ($settings['sleep']['optimal_hou