From 2cfd59871cd97e40853e9041bd7bed9a7ed56e29 Mon Sep 17 00:00:00 2001 From: Florian Heinz Date: Sat, 11 Apr 2026 20:12:21 +0200 Subject: [PATCH] Add multi-sport tracking with configurable recovery bonuses --- assets/css/app.css | 173 ++++++++++++++++++++++++- assets/icons/sport-core.svg | 5 + assets/icons/sport-row.svg | 8 ++ assets/icons/sport-run.svg | 8 ++ assets/icons/sport-strength-gym.svg | 10 ++ assets/icons/sport-strength-home.svg | 7 ++ assets/js/app.js | 182 +++++++++++++++++++++++++-- src/App.php | 88 +++++++++---- src/Domain/EntryRepository.php | 27 +++- src/Domain/ScoringService.php | 93 +++++++++++++- src/Domain/SettingsRepository.php | 8 +- src/Support/Defaults.php | 42 +++++++ src/helpers.php | 159 +++++++++++++++++++++++ templates/pages/archive.php | 28 +++++ templates/pages/options.php | 102 +++++++++++++++ templates/pages/track.php | 21 +++- 16 files changed, 926 insertions(+), 35 deletions(-) create mode 100644 assets/icons/sport-core.svg create mode 100644 assets/icons/sport-row.svg create mode 100644 assets/icons/sport-run.svg create mode 100644 assets/icons/sport-strength-gym.svg create mode 100644 assets/icons/sport-strength-home.svg diff --git a/assets/css/app.css b/assets/css/app.css index 5b6d3ee..9ec37fc 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -395,6 +395,10 @@ button { margin-bottom: 1rem; } +.section-head--compact { + margin-bottom: 0.85rem; +} + .calendar-heatmap { min-height: 0; overflow-x: auto; @@ -536,6 +540,7 @@ button { width: 100%; height: auto; display: block; + overflow: visible; } .bar-grid { @@ -561,6 +566,22 @@ button { text-anchor: middle; } +.bar-icon-ring { + fill: rgba(255, 255, 255, 0.1); + stroke: rgba(255, 255, 255, 0.18); + stroke-width: 1; +} + +.bar-icon { + opacity: 0.96; +} + +.bar-bonus-dot { + fill: rgba(255, 224, 132, 0.96); + stroke: rgba(9, 19, 31, 0.7); + stroke-width: 1; +} + .page-grid { grid-template-columns: minmax(0, 1.45fr) minmax(280px, 0.8fr); align-items: start; @@ -605,6 +626,73 @@ button { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.sport-choice-field { + display: grid; + gap: 0.7rem; + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.sport-choice-field legend { + padding: 0; + color: var(--muted); + font-size: 0.93rem; + margin-bottom: 0.1rem; +} + +.sport-choice-list { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 0.7rem; +} + +.sport-choice { + display: block; +} + +.sport-choice input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.sport-choice__card { + display: grid; + gap: 0.35rem; + min-height: 100%; + padding: 0.85rem 0.95rem; + border-radius: 18px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.08); + transition: transform 180ms ease, border-color 180ms ease, background 180ms ease, box-shadow 180ms ease; +} + +.sport-choice__card img { + width: 1.2rem; + height: 1.2rem; + opacity: 0.96; +} + +.sport-choice__card strong { + color: var(--text); + font-size: 0.92rem; + line-height: 1.35; +} + +.sport-choice input:checked + .sport-choice__card { + border-color: rgba(139, 228, 255, 0.44); + background: linear-gradient(180deg, rgba(96, 184, 255, 0.18), rgba(255, 255, 255, 0.08)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 16px 28px rgba(8, 28, 43, 0.2); + transform: translateY(-1px); +} + +.sport-choice input:focus-visible + .sport-choice__card { + outline: 2px solid rgba(139, 228, 255, 0.46); + outline-offset: 2px; +} + label { display: grid; gap: 0.55rem; @@ -631,6 +719,16 @@ textarea { transition: border-color 180ms ease, background 180ms ease, transform 180ms ease; } +select { + color-scheme: dark; +} + +option, +optgroup { + background: #10253a; + color: var(--text); +} + input:focus, select:focus, textarea:focus { @@ -802,6 +900,11 @@ input[type="range"] { border-radius: 999px; } +.ghost-button--small { + min-height: 2.45rem; + padding: 0.55rem 0.95rem; +} + .archive-items, .user-list { display: grid; @@ -848,6 +951,52 @@ input[type="range"] { padding-inline: 0.85rem; } +.sport-pill { + display: inline-flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.55rem; + padding: 0.45rem 0.75rem; + border-radius: 999px; + background: rgba(118, 228, 255, 0.1); + border: 1px solid rgba(118, 228, 255, 0.14); + color: var(--text); + line-height: 1; +} + +.sport-pill img { + width: 1rem; + height: 1rem; + opacity: 0.95; +} + +.sport-pill span { + margin-top: 0; + color: inherit; +} + +.sport-pill--soft { + margin-top: 0; + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.12); +} + +.sport-pill--inline { + margin-top: 0; + vertical-align: middle; +} + +.sport-pill-group { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin-top: 0.45rem; +} + +.sport-pill-group--inline { + margin-top: 0; +} + .note-box { padding: 1rem; border-radius: 18px; @@ -875,6 +1024,23 @@ input[type="range"] { background: rgba(255, 255, 255, 0.08); } +.sport-type-list { + display: grid; + gap: 0.9rem; +} + +.sport-type-card { + gap: 0.9rem; +} + +.sport-type-card__actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.8rem; + flex-wrap: wrap; +} + .checkbox-row { display: flex; align-items: center; @@ -1000,13 +1166,18 @@ input[type="range"] { grid-template-columns: 1fr; } + .sport-choice-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .bar-chart { overflow-x: auto; padding-bottom: 0.4rem; } .archive-item, - .preview-status { + .preview-status, + .sport-type-card__actions { flex-direction: column; align-items: flex-start; } diff --git a/assets/icons/sport-core.svg b/assets/icons/sport-core.svg new file mode 100644 index 0000000..fa93611 --- /dev/null +++ b/assets/icons/sport-core.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/sport-row.svg b/assets/icons/sport-row.svg new file mode 100644 index 0000000..db984ff --- /dev/null +++ b/assets/icons/sport-row.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/sport-run.svg b/assets/icons/sport-run.svg new file mode 100644 index 0000000..ee32667 --- /dev/null +++ b/assets/icons/sport-run.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/sport-strength-gym.svg b/assets/icons/sport-strength-gym.svg new file mode 100644 index 0000000..bfd96c7 --- /dev/null +++ b/assets/icons/sport-strength-gym.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/icons/sport-strength-home.svg b/assets/icons/sport-strength-home.svg new file mode 100644 index 0000000..a8ae699 --- /dev/null +++ b/assets/icons/sport-strength-home.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/js/app.js b/assets/js/app.js index b45b75e..da571eb 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -39,6 +39,10 @@ return `/assets/icons/mood-${sentiment}.svg`; } + function sportIconPath(icon) { + return `/assets/icons/sport-${icon}.svg`; + } + function updateRangeOutputs() { document.querySelectorAll("[data-output-for]").forEach(output => { const input = document.querySelector(`[name="${output.dataset.outputFor}"]`); @@ -113,6 +117,62 @@ return [...(ratings || [])].sort((a, b) => Number(a.min || 0) - Number(b.min || 0)); } + function findSportType(settings, id) { + if (!id) { + return null; + } + + return (settings.sport_types || []).find(type => type.id === id) || null; + } + + function findSportTypes(settings, ids) { + const selected = Array.isArray(ids) ? ids : (ids ? [ids] : []); + return selected.map(id => findSportType(settings, id)).filter(Boolean); + } + + function sportBonusPoints(entry, settings, previousEntry) { + const currentSportTypes = findSportTypes(settings, entry.sport_types || entry.sport_type || []); + + if (Number(entry.sport_minutes || 0) <= 0 || !currentSportTypes.length) { + return 0; + } + + const previousGroups = new Set(); + if (previousEntry && Number(previousEntry.sport_minutes || 0) > 0) { + for (const type of findSportTypes(settings, previousEntry.sport_types || previousEntry.sport_type || [])) { + previousGroups.add(type.recovery_group || type.id); + } + } + + let total = 0; + const groupBonuses = new Map(); + + for (const type of currentSportTypes) { + const bonus = Number(type.bonus_points || 0); + if (bonus <= 0) { + continue; + } + + const group = type.recovery_group || type.id; + if (type.allow_consecutive) { + total += bonus; + continue; + } + + if (previousGroups.has(group)) { + continue; + } + + groupBonuses.set(group, Math.max(Number(groupBonuses.get(group) || 0), bonus)); + } + + for (const bonus of groupBonuses.values()) { + total += bonus; + } + + return total; + } + function labelForScore(score, ratings) { for (const rating of ratings) { if (score >= Number(rating.min || 0) && score <= Number(rating.max || 0)) { @@ -168,7 +228,7 @@ return "radiant"; } - function evaluateEntry(entry, settings) { + function evaluateEntry(entry, settings, previousEntry = null) { const ratings = sortedRatings(settings.ratings || []); const scoring = settings.scoring || {}; const components = { @@ -178,6 +238,7 @@ sleep_hours: sleepDurationPoints(Number(entry.sleep_hours), scoring.sleep_duration_points || {}), sleep_feeling: Number(entry.sleep_feeling) * Number(scoring.sleep_feeling_multiplier || 0), sport_minutes: bandPoints(Number(entry.sport_minutes), scoring.sport_bands || []), + sport_bonus: sportBonusPoints(entry, settings, previousEntry), walk_minutes: bandPoints(Number(entry.walk_minutes), scoring.walk_bands || []), note: String(entry.note || "").trim() === "" ? 0 : Number(scoring.journal_points || 0), }; @@ -223,6 +284,7 @@ sleep_hours: "Schlafdauer", sleep_feeling: "Schlafgefühl", sport_minutes: "Sport", + sport_bonus: "Sportbonus", walk_minutes: "Spaziergang", note: "Notiz", }; @@ -234,12 +296,13 @@ sleep_hours: Number(form.elements.sleep_hours.value || 0), sleep_feeling: Number(form.elements.sleep_feeling.value), sport_minutes: Number(form.elements.sport_minutes.value || 0), + sport_types: [...form.querySelectorAll('input[name="sport_types[]"]:checked')].map(input => input.value), walk_minutes: Number(form.elements.walk_minutes.value || 0), note: form.elements.note.value || "", }); const render = () => { - const result = evaluateEntry(collect(), payload.settings); + const result = evaluateEntry(collect(), payload.settings, payload.previousEntry || null); totalNode.textContent = formatNumber(result.total); labelNode.textContent = result.label; iconNode.src = moodIconPath(result.sentiment); @@ -369,9 +432,9 @@ const recent = items.slice(-18); const maxValue = Math.max(...recent.map(item => Number(item.value)), 1); const width = Math.max(recent.length * 34, 520); - const height = 184; - const chartHeight = 118; - const baseY = 146; + const height = 194; + const chartHeight = 116; + const baseY = 150; const bars = recent.map((item, index) => { const sport = Number(item.sport || 0); @@ -384,6 +447,21 @@ const backgroundY = baseY - chartHeight; const walkY = baseY - walkHeight; const sportY = walkY - sportHeight; + const badgeY = total > 0 ? Math.max(backgroundY + 6, sportY - 24) : (baseY - 20); + const labels = Array.isArray(item.sport_labels) ? item.sport_labels.filter(Boolean) : []; + const label = labels.length ? ` · ${labels.join(", ")}` : ""; + const bonus = Number(item.sport_bonus || 0); + const icons = Array.isArray(item.sport_icons) ? item.sport_icons.slice(0, 3) : []; + const iconMarkup = icons.length && sport > 0 + ? icons.map((icon, iconIndex) => { + const iconX = x - 7 + (iconIndex * 10); + const iconY = badgeY - Math.max(0, (icons.length - 1) * 2) + (iconIndex * 2); + return ` + + + `; + }).join("") + (bonus > 0 ? `` : "") + : ""; return ` @@ -391,10 +469,11 @@ ${formatDateLabel(item.date)} · Spaziergang ${walk} min - ${formatDateLabel(item.date)} · Sport ${sport} min + ${formatDateLabel(item.date)} · Sport ${sport} min${label}${bonus > 0 ? ` · Bonus ${formatNumber(bonus)}` : ""} + ${iconMarkup} ${Math.round(total)} - ${formatDateLabel(item.date)} + ${formatDateLabel(item.date)} `; }).join(""); @@ -563,6 +642,94 @@ }); } + function initSportTypeManager() { + const list = document.querySelector("[data-sport-type-list]"); + const addButton = document.querySelector("[data-add-sport-type]"); + const template = document.querySelector("#sport-type-row-template"); + + if (!list || !addButton || !template) { + return; + } + + const syncPreview = row => { + const labelInput = row.querySelector('input[data-name-template$="[label]"]'); + const iconSelect = row.querySelector('select[data-name-template$="[icon]"]'); + const previewText = row.querySelector(".sport-pill span:last-child"); + const previewImage = row.querySelector(".sport-pill img"); + + if (previewText) { + previewText.textContent = (labelInput && labelInput.value.trim()) || "Neue Sportart"; + } + + if (previewImage && iconSelect) { + previewImage.src = sportIconPath(iconSelect.value || "run"); + } + }; + + const renumber = () => { + [...list.querySelectorAll("[data-sport-type-row]")].forEach((row, index) => { + row.querySelectorAll("[data-name-template]").forEach(field => { + field.name = field.dataset.nameTemplate.replace(/__INDEX__/g, String(index)); + }); + syncPreview(row); + }); + }; + + addButton.addEventListener("click", () => { + list.append(template.content.cloneNode(true)); + renumber(); + }); + + list.addEventListener("click", event => { + const button = event.target.closest("[data-remove-sport-type]"); + if (!button) { + return; + } + + const row = button.closest("[data-sport-type-row]"); + if (!row) { + return; + } + + const rows = list.querySelectorAll("[data-sport-type-row]"); + if (rows.length <= 1) { + row.querySelectorAll("input[type='text'], input[type='hidden']").forEach(input => { + input.value = ""; + }); + row.querySelectorAll("input[type='number']").forEach(input => { + input.value = "2"; + }); + row.querySelectorAll("input[type='checkbox']").forEach(input => { + input.checked = false; + }); + row.querySelectorAll("select").forEach(select => { + select.value = "run"; + }); + syncPreview(row); + return; + } + + row.remove(); + renumber(); + }); + + list.addEventListener("input", event => { + const row = event.target.closest("[data-sport-type-row]"); + if (row) { + syncPreview(row); + } + }); + + list.addEventListener("change", event => { + const row = event.target.closest("[data-sport-type-row]"); + if (row) { + syncPreview(row); + } + }); + + renumber(); + } + window.addEventListener("resize", () => { if (!document.querySelector("#calendar-heatmap")) { return; @@ -578,4 +745,5 @@ initHeaderDatePicker(); initTrackPreview(); initDashboardCharts(); + initSportTypeManager(); })(); diff --git a/src/App.php b/src/App.php index 6b5596e..34402c6 100644 --- a/src/App.php +++ b/src/App.php @@ -184,16 +184,9 @@ final class App private function showDashboard(): void { $user = $this->requireUser(); - $settings = $this->settings->forUser($user['username']); + $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); $entries = $this->entries->all($user['username']); - - $evaluatedEntries = []; - foreach ($entries as $entry) { - $evaluation = $this->scoring->evaluate($entry, $settings); - $evaluatedEntries[] = array_merge($entry, ['evaluation' => $evaluation]); - } - - usort($evaluatedEntries, static fn (array $a, array $b): int => strcmp($a['date'], $b['date'])); + $evaluatedEntries = $this->evaluateEntriesWithContext($entries, $settings); $summary = $this->buildDashboardSummary($evaluatedEntries); $chartData = $this->buildDashboardCharts($evaluatedEntries); @@ -211,7 +204,7 @@ final class App private function showTrack(): void { $user = $this->requireUser(); - $settings = $this->settings->forUser($user['username']); + $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); $date = (string) ($_GET['date'] ?? today()); if (!$this->isValidDate($date)) { $date = today(); @@ -224,12 +217,15 @@ final class App 'sleep_hours' => 7, 'sleep_feeling' => 3, 'sport_minutes' => 0, + 'sport_type' => '', + 'sport_types' => [], 'walk_minutes' => 0, 'note' => '', ]; $entry = $this->scoring->normalize($entry); - $evaluation = $this->scoring->evaluate($entry, $settings); + $previousEntry = $this->entries->find($user['username'], shift_date($date, -1)); + $evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry); View::render('track', [ 'pageTitle' => 'Tag tracken', @@ -238,11 +234,13 @@ final class App 'entry' => $entry, 'evaluation' => $evaluation, 'settings' => $settings, + 'sportTypes' => normalized_sport_types($settings), 'trackMood' => $evaluation['sentiment'], 'topbarDate' => $entry['date'], 'trackPayload' => encode_payload([ 'settings' => $settings, 'entry' => $entry, + 'previousEntry' => $previousEntry !== null ? $this->scoring->normalize($previousEntry) : null, ]), ]); } @@ -252,7 +250,7 @@ final class App $this->enforceCsrf(); $user = $this->requireUser(); - $settings = $this->settings->forUser($user['username']); + $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); $entry = $this->scoring->normalize([ 'date' => $_POST['date'] ?? today(), @@ -262,6 +260,7 @@ final class App 'sleep_hours' => $_POST['sleep_hours'] ?? 0, 'sleep_feeling' => $_POST['sleep_feeling'] ?? 3, 'sport_minutes' => $_POST['sport_minutes'] ?? 0, + 'sport_types' => $_POST['sport_types'] ?? [], 'walk_minutes' => $_POST['walk_minutes'] ?? 0, 'note' => $_POST['note'] ?? '', ]); @@ -271,7 +270,8 @@ final class App redirect('/track'); } - $evaluation = $this->scoring->evaluate($entry, $settings); + $previousEntry = $this->entries->find($user['username'], shift_date($entry['date'], -1)); + $evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry); $this->entries->save($user['username'], $entry['date'], $entry, $evaluation); flash('success', 'Der Tag wurde gespeichert.'); @@ -281,16 +281,10 @@ final class App private function showArchive(): void { $user = $this->requireUser(); - $settings = $this->settings->forUser($user['username']); + $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); $selectedDate = isset($_GET['date']) ? (string) $_GET['date'] : null; $entries = $this->entries->all($user['username']); - - $archive = []; - foreach ($entries as $entry) { - $archive[] = array_merge($entry, [ - 'evaluation' => $this->scoring->evaluate($entry, $settings), - ]); - } + $archive = array_reverse($this->evaluateEntriesWithContext($entries, $settings)); $selectedEntry = null; if ($selectedDate !== null) { @@ -314,7 +308,7 @@ final class App private function showOptions(): void { $user = $this->requireUser(); - $settings = $this->settings->forUser($user['username']); + $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); View::render('options', [ 'pageTitle' => 'Optionen', @@ -329,6 +323,10 @@ final class App 'sleep_hours' => 7, 'sleep_feeling' => 5, 'sport_minutes' => 999, + 'sport_types' => array_map( + static fn (array $type): string => (string) ($type['id'] ?? ''), + normalized_sport_types($settings) + ), 'walk_minutes' => 999, 'note' => 'x', ], $settings)['max_total'], @@ -467,6 +465,15 @@ final class App 'value' => $entry['sport_minutes'] + $entry['walk_minutes'], 'sport' => $entry['sport_minutes'], 'walk' => $entry['walk_minutes'], + 'sport_labels' => array_values(array_filter(array_map( + static fn (array $type): string => (string) ($type['label'] ?? ''), + $entry['sport_type_meta'] ?? [] + ))), + 'sport_icons' => array_values(array_filter(array_map( + static fn (array $type): ?string => isset($type['icon']) ? sport_icon_path((string) $type['icon']) : null, + $entry['sport_type_meta'] ?? [] + ))), + 'sport_bonus' => (float) ($entry['evaluation']['components']['sport_bonus'] ?? 0), ]; }, $recent), ]; @@ -542,9 +549,46 @@ final class App ]; } + $settings['sport_types'] = normalized_sport_types([ + 'sport_types' => is_array($input['sport_types'] ?? null) + ? $input['sport_types'] + : $defaults['sport_types'], + ]); + return $settings; } + private function hydrateSettings(array $settings): array + { + $settings['sport_types'] = normalized_sport_types($settings); + + return $settings; + } + + private function evaluateEntriesWithContext(array $entries, array $settings): array + { + $normalized = array_map(fn (array $entry): array => $this->scoring->normalize($entry), $entries); + usort($normalized, static fn (array $a, array $b): int => strcmp($a['date'], $b['date'])); + + $entryMap = []; + foreach ($normalized as $entry) { + $entryMap[$entry['date']] = $entry; + } + + $evaluated = []; + foreach ($normalized as $entry) { + $previousEntry = $entryMap[shift_date($entry['date'], -1)] ?? null; + $evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry); + + $evaluated[] = array_merge($entry, [ + 'evaluation' => $evaluation, + 'sport_type_meta' => find_sport_types($settings, $entry['sport_types']), + ]); + } + + return $evaluated; + } + private function sendSecurityHeaders(): void { header('Referrer-Policy: strict-origin-when-cross-origin'); diff --git a/src/Domain/EntryRepository.php b/src/Domain/EntryRepository.php index 786c05d..08fadc9 100644 --- a/src/Domain/EntryRepository.php +++ b/src/Domain/EntryRepository.php @@ -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 = [ - '', + '', '# 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']), '', diff --git a/src/Domain/ScoringService.php b/src/Domain/ScoringService.php index 3bf8673..035538e 100644 --- a/src/Domain/ScoringService.php +++ b/src/Domain/ScoringService.php @@ -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'])); diff --git a/src/Domain/SettingsRepository.php b/src/Domain/SettingsRepository.php index 4983143..d5f1e68 100644 --- a/src/Domain/SettingsRepository.php +++ b/src/Domain/SettingsRepository.php @@ -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'); } } - diff --git a/src/Support/Defaults.php b/src/Support/Defaults.php index b4843d6..c154535 100644 --- a/src/Support/Defaults.php +++ b/src/Support/Defaults.php @@ -16,6 +16,48 @@ final class Defaults 5 => 'sehr ausgeschlafen', ], ], + 'sport_types' => [ + [ + 'id' => 'strength-home', + 'label' => 'Kraftsport (Keller)', + 'icon' => 'strength-home', + 'recovery_group' => 'kraftsport', + 'bonus_points' => 2, + 'allow_consecutive' => false, + ], + [ + 'id' => 'strength-gym', + 'label' => 'Kraftsport (Gym)', + 'icon' => 'strength-gym', + 'recovery_group' => 'kraftsport', + 'bonus_points' => 2, + 'allow_consecutive' => false, + ], + [ + 'id' => 'running', + 'label' => 'Joggen', + 'icon' => 'run', + 'recovery_group' => 'joggen', + 'bonus_points' => 2, + 'allow_consecutive' => false, + ], + [ + 'id' => 'rowing', + 'label' => 'Rudergerät', + 'icon' => 'row', + 'recovery_group' => 'rudern', + 'bonus_points' => 2, + 'allow_consecutive' => false, + ], + [ + 'id' => 'core', + 'label' => 'Core', + 'icon' => 'core', + 'recovery_group' => 'core', + 'bonus_points' => 2, + 'allow_consecutive' => true, + ], + ], 'scoring' => [ 'mood_multiplier' => 3, 'energy_multiplier' => 2, diff --git a/src/helpers.php b/src/helpers.php index 569388a..7f2c569 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -175,3 +175,162 @@ function mood_icon_path(string $sentiment): string { return icon_path('mood-' . $sentiment); } + +function sport_icon_path(string $icon): string +{ + return icon_path('sport-' . $icon); +} + +function sport_icon_options(): array +{ + return [ + 'strength-home' => 'Kraftsport daheim', + 'strength-gym' => 'Kraftsport im Gym', + 'run' => 'Joggen', + 'row' => 'Rudergerät', + 'core' => 'Core', + ]; +} + +function normalize_sport_type_id(string $value): string +{ + $value = trim(strtr($value, [ + 'Ä' => 'ae', + 'Ö' => 'oe', + 'Ü' => 'ue', + 'ä' => 'ae', + 'ö' => 'oe', + 'ü' => 'ue', + 'ß' => 'ss', + ])); + $value = strtolower($value); + $value = strtr($value, [ + '--' => '-', + ]); + $value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?? ''; + + return trim($value, '-'); +} + +function normalized_sport_types(array $settings): array +{ + $types = $settings['sport_types'] ?? []; + $normalized = []; + $usedIds = []; + $iconOptions = sport_icon_options(); + + foreach ($types as $index => $type) { + if (!is_array($type)) { + continue; + } + + $label = trim((string) ($type['label'] ?? '')); + if ($label === '') { + continue; + } + + $candidateId = trim((string) ($type['id'] ?? '')); + if ($candidateId === '') { + $candidateId = normalize_sport_type_id($label); + } + + if ($candidateId === '') { + $candidateId = 'sportart'; + } + + $id = $candidateId; + $suffix = 2; + while (isset($usedIds[$id])) { + $id = $candidateId . '-' . $suffix; + $suffix++; + } + + $usedIds[$id] = true; + + $icon = trim((string) ($type['icon'] ?? 'run')); + if (!array_key_exists($icon, $iconOptions)) { + $icon = 'run'; + } + + $group = trim((string) ($type['recovery_group'] ?? '')); + if ($group === '') { + $group = $id; + } + + $normalized[] = [ + 'id' => $id, + 'label' => $label, + 'icon' => $icon, + 'recovery_group' => normalize_sport_type_id($group) ?: $id, + 'bonus_points' => max(0, min(20, (int) ($type['bonus_points'] ?? 0))), + 'allow_consecutive' => !empty($type['allow_consecutive']), + ]; + } + + return $normalized; +} + +function normalize_sport_type_selection(mixed $value): array +{ + if (is_string($value)) { + $value = trim($value); + if ($value === '') { + return []; + } + + if (str_contains($value, ',')) { + $value = array_map('trim', explode(',', $value)); + } else { + $value = [$value]; + } + } + + if (!is_array($value)) { + return []; + } + + $normalized = []; + foreach ($value as $item) { + if (!is_string($item)) { + continue; + } + + $id = trim($item); + if ($id === '' || isset($normalized[$id])) { + continue; + } + + $normalized[$id] = true; + } + + return array_keys($normalized); +} + +function find_sport_type(array $settings, ?string $id): ?array +{ + if (!is_string($id) || trim($id) === '') { + return null; + } + + foreach (normalized_sport_types($settings) as $type) { + if (($type['id'] ?? '') === $id) { + return $type; + } + } + + return null; +} + +function find_sport_types(array $settings, array $ids): array +{ + $types = []; + + foreach (normalize_sport_type_selection($ids) as $id) { + $type = find_sport_type($settings, $id); + if ($type !== null) { + $types[] = $type; + } + } + + return $types; +} diff --git a/templates/pages/archive.php b/templates/pages/archive.php index 139ac6b..f73e62c 100644 --- a/templates/pages/archive.php +++ b/templates/pages/archive.php @@ -17,6 +17,16 @@
+ 0 && !empty($entry['sport_type_meta'])): ?> + + + + + + + + +
@@ -47,6 +57,24 @@
Schlaf
h
Schlafgefühl
/5
Sport
min
+
+
Sportarten
+
+ 0 && !empty($selectedEntry['sport_type_meta'])): ?> + + + + + + + + + + keine + +
+
+
Sportbonus
Spaziergang
min
diff --git a/templates/pages/options.php b/templates/pages/options.php index 4ffe92d..9378de4 100644 --- a/templates/pages/options.php +++ b/templates/pages/options.php @@ -60,6 +60,108 @@
+
+
+
+

Sportarten und Bonuspunkte

+

Lege fest, welche Sportarten im Tracking auswählbar sind. Der Bonus gilt nur, wenn am Vortag keine gleiche Erholungsgruppe trainiert wurde.

+
+ +
+ +
+ $sportType): ?> +
+ + +
+ + + + + + + +
+ + + +
+ + + + + +
+
+ +
+ + +
+

Bewertungsskala

diff --git a/templates/pages/track.php b/templates/pages/track.php index 6c74805..86cdb22 100644 --- a/templates/pages/track.php +++ b/templates/pages/track.php @@ -11,7 +11,7 @@
-
+ @@ -65,6 +65,24 @@ +
+
+ Sportarten +
+ + + + +
+
+
+