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
= e(format_display_date((string) $dayEntry['date'], false)) ?>