Add multi-sport tracking with configurable recovery bonuses

This commit is contained in:
2026-04-11 20:12:21 +02:00
parent 3e5cdfb717
commit 2cfd59871c
16 changed files with 926 additions and 35 deletions
+26 -1
View File
@@ -62,6 +62,21 @@ final class EntryRepository
private function parse(string $content, string $fallbackDate): ?array
{
$sportTypes = [];
$sportTypesRaw = (string) ($this->extract('/^- Sportarten:\s*(.*)$/mu', $content) ?? '');
if ($sportTypesRaw !== '') {
preg_match_all('/\[([^\]]+)\]/u', $sportTypesRaw, $matches);
$sportTypes = normalize_sport_type_selection($matches[1] ?? []);
}
if ($sportTypes === []) {
$sportType = (string) ($this->extract('/^- Sportart:\s*(.*)$/mu', $content) ?? '');
if (preg_match('/\[(.+)\]\s*$/u', $sportType, $matches)) {
$sportType = trim((string) ($matches[1] ?? ''));
}
$sportTypes = normalize_sport_type_selection($sportType);
}
$entry = [
'date' => $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate,
'mood' => (int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5),
@@ -70,6 +85,8 @@ final class EntryRepository
'sleep_hours' => (float) ($this->extract('/^- Schlafdauer:\s*(.+)$/m', $content) ?? 0),
'sleep_feeling' => (int) ($this->extract('/^- Schlaf(?:gefühl|gefuehl):\s*(.+)$/mu', $content) ?? 3),
'sport_minutes' => (int) ($this->extract('/^- Sport:\s*(.+)$/m', $content) ?? 0),
'sport_type' => $sportTypes[0] ?? '',
'sport_types' => $sportTypes,
'walk_minutes' => (int) ($this->extract('/^- Spaziergang:\s*(.+)$/m', $content) ?? 0),
'note' => $this->extractNote($content),
];
@@ -101,8 +118,14 @@ final class EntryRepository
private function toMarkdown(string $username, string $date, array $entry, array $evaluation): string
{
$sportTypes = $evaluation['sport_types'] ?? [];
$sportTypeValues = array_map(
static fn (array $type): string => (string) $type['label'] . ' [' . (string) $type['id'] . ']',
array_filter($sportTypes, 'is_array')
);
$lines = [
'<!-- mood-tracker:v1 -->',
'<!-- mood-tracker:v2 -->',
'# Stimmungstracker',
'Datum: ' . $date,
'Benutzer: ' . normalize_username($username),
@@ -114,6 +137,7 @@ final class EntryRepository
'- Schlafdauer: ' . $entry['sleep_hours'],
'- Schlafgefühl: ' . $entry['sleep_feeling'],
'- Sport: ' . $entry['sport_minutes'],
'- Sportarten: ' . implode(', ', $sportTypeValues),
'- Spaziergang: ' . $entry['walk_minutes'],
'',
'## Bewertung',
@@ -127,6 +151,7 @@ final class EntryRepository
'- Schlafdauer: ' . format_points((float) $evaluation['components']['sleep_hours']),
'- Schlafgefühl: ' . format_points((float) $evaluation['components']['sleep_feeling']),
'- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']),
'- Sportbonus: ' . format_points((float) ($evaluation['components']['sport_bonus'] ?? 0)),
'- Spaziergang: ' . format_points((float) $evaluation['components']['walk_minutes']),
'- Notiz: ' . format_points((float) $evaluation['components']['note']),
'',
+92 -1
View File
@@ -6,6 +6,8 @@ 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))),
@@ -14,16 +16,21 @@ final class ScoringService
'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
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'],
@@ -32,6 +39,7 @@ final class ScoringService
'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'],
];
@@ -44,6 +52,7 @@ final class ScoringService
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
@@ -74,6 +83,8 @@ final class ScoringService
'guardrail' => $guardrail,
'sentiment' => $this->sentimentForLabel($label, $ratings),
'percentage' => $maxTotal > 0 ? round(($total / $maxTotal) * 100, 1) : 0,
'sport_type' => $sportTypes[0] ?? null,
'sport_types' => $sportTypes,
];
}
@@ -135,6 +146,86 @@ final class ScoringService
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']));
+6 -2
View File
@@ -8,8 +8,13 @@ final class SettingsRepository
{
$path = $this->pathFor($username);
$saved = decode_json_file($path, []);
$settings = array_replace_recursive(Defaults::settings(), $saved);
return array_replace_recursive(Defaults::settings(), $saved);
if (array_key_exists('sport_types', $saved) && is_array($saved['sport_types'])) {
$settings['sport_types'] = $saved['sport_types'];
}
return $settings;
}
public function saveForUser(string $username, array $settings): void
@@ -32,4 +37,3 @@ final class SettingsRepository
return storage_path('users/' . normalize_username($username) . '/settings.json');
}
}