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 @@
= e(format_display_date($entry['date'], false)) ?>
= e($entry['evaluation']['label']) ?>
+ 0 && !empty($entry['sport_type_meta'])): ?>
+
+
+
+
+ = e($sportType['label']) ?>
+
+
+
+
+
+
+
+
Sportarten und Bonuspunkte
+
Lege fest, welche Sportarten im Tracking auswählbar sind. Der Bonus gilt nur, wenn am Vortag keine gleiche Erholungsgruppe trainiert wurde.
+
+
+
+
+
+
+
+
+
+
+
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 @@
-