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
+172 -1
View File
@@ -395,6 +395,10 @@ button {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.section-head--compact {
margin-bottom: 0.85rem;
}
.calendar-heatmap { .calendar-heatmap {
min-height: 0; min-height: 0;
overflow-x: auto; overflow-x: auto;
@@ -536,6 +540,7 @@ button {
width: 100%; width: 100%;
height: auto; height: auto;
display: block; display: block;
overflow: visible;
} }
.bar-grid { .bar-grid {
@@ -561,6 +566,22 @@ button {
text-anchor: middle; 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 { .page-grid {
grid-template-columns: minmax(0, 1.45fr) minmax(280px, 0.8fr); grid-template-columns: minmax(0, 1.45fr) minmax(280px, 0.8fr);
align-items: start; align-items: start;
@@ -605,6 +626,73 @@ button {
grid-template-columns: repeat(4, minmax(0, 1fr)); 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 { label {
display: grid; display: grid;
gap: 0.55rem; gap: 0.55rem;
@@ -631,6 +719,16 @@ textarea {
transition: border-color 180ms ease, background 180ms ease, transform 180ms ease; transition: border-color 180ms ease, background 180ms ease, transform 180ms ease;
} }
select {
color-scheme: dark;
}
option,
optgroup {
background: #10253a;
color: var(--text);
}
input:focus, input:focus,
select:focus, select:focus,
textarea:focus { textarea:focus {
@@ -802,6 +900,11 @@ input[type="range"] {
border-radius: 999px; border-radius: 999px;
} }
.ghost-button--small {
min-height: 2.45rem;
padding: 0.55rem 0.95rem;
}
.archive-items, .archive-items,
.user-list { .user-list {
display: grid; display: grid;
@@ -848,6 +951,52 @@ input[type="range"] {
padding-inline: 0.85rem; 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 { .note-box {
padding: 1rem; padding: 1rem;
border-radius: 18px; border-radius: 18px;
@@ -875,6 +1024,23 @@ input[type="range"] {
background: rgba(255, 255, 255, 0.08); 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 { .checkbox-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1000,13 +1166,18 @@ input[type="range"] {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.sport-choice-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.bar-chart { .bar-chart {
overflow-x: auto; overflow-x: auto;
padding-bottom: 0.4rem; padding-bottom: 0.4rem;
} }
.archive-item, .archive-item,
.preview-status { .preview-status,
.sport-type-card__actions {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
} }
+5
View File
@@ -0,0 +1,5 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="32" cy="32" r="18" fill="#8BE4FF" fill-opacity="0.16"/>
<path d="M32 15L37.5 23.5L47 25L40 32L41.8 41.5L32 36.5L22.2 41.5L24 32L17 25L26.5 23.5L32 15Z" fill="#EFF7FF"/>
<circle cx="32" cy="32" r="5.5" fill="#7FF3BB" fill-opacity="0.86"/>
</svg>

After

Width:  |  Height:  |  Size: 359 B

+8
View File
@@ -0,0 +1,8 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="22" cy="18" r="5" fill="#EFF7FF"/>
<path d="M18 29L26 23L34 28" stroke="#8BE4FF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M26 23L28 37L40 44" stroke="#EFF7FF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M40 24L52 15" stroke="#7FF3BB" stroke-width="4" stroke-linecap="round"/>
<path d="M46 20L52 15L51 28" stroke="#7FF3BB" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 49C19 45 29 43 40 43C46 43 51 43.8 56 45.5" stroke="#8BE4FF" stroke-width="3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 696 B

+8
View File
@@ -0,0 +1,8 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="39" cy="13" r="6" fill="#EFF7FF"/>
<path d="M26 28L35 20L43 23" stroke="#8BE4FF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M35 20L32 32L40 38" stroke="#EFF7FF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M32 32L22 41" stroke="#7FF3BB" stroke-width="4" stroke-linecap="round"/>
<path d="M40 38L47 48" stroke="#7FF3BB" stroke-width="4" stroke-linecap="round"/>
<path d="M20 51H50" stroke="#8BE4FF" stroke-width="3" stroke-linecap="round" opacity="0.75"/>
</svg>

After

Width:  |  Height:  |  Size: 644 B

+10
View File
@@ -0,0 +1,10 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="7" y="24" width="9" height="16" rx="3" fill="#8BE4FF" fill-opacity="0.9"/>
<rect x="16" y="27" width="6" height="10" rx="2" fill="#8BE4FF" fill-opacity="0.72"/>
<rect x="42" y="27" width="6" height="10" rx="2" fill="#8BE4FF" fill-opacity="0.72"/>
<rect x="48" y="24" width="9" height="16" rx="3" fill="#8BE4FF" fill-opacity="0.9"/>
<rect x="22" y="29" width="20" height="6" rx="3" fill="#EFF7FF"/>
<path d="M24 47H40" stroke="#7FF3BB" stroke-width="4" stroke-linecap="round"/>
<path d="M28 43V51" stroke="#7FF3BB" stroke-width="4" stroke-linecap="round"/>
<path d="M36 43V51" stroke="#7FF3BB" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 763 B

+7
View File
@@ -0,0 +1,7 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="8" y="26" width="10" height="12" rx="3" fill="#8BE4FF" fill-opacity="0.9"/>
<rect x="46" y="26" width="10" height="12" rx="3" fill="#8BE4FF" fill-opacity="0.9"/>
<rect x="18" y="28" width="28" height="8" rx="4" fill="#EFF7FF"/>
<path d="M22 44C22 39.5817 25.5817 36 30 36H34C38.4183 36 42 39.5817 42 44V50H22V44Z" fill="#7FF3BB" fill-opacity="0.82"/>
<path d="M28 24L32 18L36 24" stroke="#EFF7FF" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 585 B

+175 -7
View File
@@ -39,6 +39,10 @@
return `/assets/icons/mood-${sentiment}.svg`; return `/assets/icons/mood-${sentiment}.svg`;
} }
function sportIconPath(icon) {
return `/assets/icons/sport-${icon}.svg`;
}
function updateRangeOutputs() { function updateRangeOutputs() {
document.querySelectorAll("[data-output-for]").forEach(output => { document.querySelectorAll("[data-output-for]").forEach(output => {
const input = document.querySelector(`[name="${output.dataset.outputFor}"]`); 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)); 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) { function labelForScore(score, ratings) {
for (const rating of ratings) { for (const rating of ratings) {
if (score >= Number(rating.min || 0) && score <= Number(rating.max || 0)) { if (score >= Number(rating.min || 0) && score <= Number(rating.max || 0)) {
@@ -168,7 +228,7 @@
return "radiant"; return "radiant";
} }
function evaluateEntry(entry, settings) { function evaluateEntry(entry, settings, previousEntry = null) {
const ratings = sortedRatings(settings.ratings || []); const ratings = sortedRatings(settings.ratings || []);
const scoring = settings.scoring || {}; const scoring = settings.scoring || {};
const components = { const components = {
@@ -178,6 +238,7 @@
sleep_hours: sleepDurationPoints(Number(entry.sleep_hours), scoring.sleep_duration_points || {}), sleep_hours: sleepDurationPoints(Number(entry.sleep_hours), scoring.sleep_duration_points || {}),
sleep_feeling: Number(entry.sleep_feeling) * Number(scoring.sleep_feeling_multiplier || 0), sleep_feeling: Number(entry.sleep_feeling) * Number(scoring.sleep_feeling_multiplier || 0),
sport_minutes: bandPoints(Number(entry.sport_minutes), scoring.sport_bands || []), 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 || []), walk_minutes: bandPoints(Number(entry.walk_minutes), scoring.walk_bands || []),
note: String(entry.note || "").trim() === "" ? 0 : Number(scoring.journal_points || 0), note: String(entry.note || "").trim() === "" ? 0 : Number(scoring.journal_points || 0),
}; };
@@ -223,6 +284,7 @@
sleep_hours: "Schlafdauer", sleep_hours: "Schlafdauer",
sleep_feeling: "Schlafgefühl", sleep_feeling: "Schlafgefühl",
sport_minutes: "Sport", sport_minutes: "Sport",
sport_bonus: "Sportbonus",
walk_minutes: "Spaziergang", walk_minutes: "Spaziergang",
note: "Notiz", note: "Notiz",
}; };
@@ -234,12 +296,13 @@
sleep_hours: Number(form.elements.sleep_hours.value || 0), sleep_hours: Number(form.elements.sleep_hours.value || 0),
sleep_feeling: Number(form.elements.sleep_feeling.value), sleep_feeling: Number(form.elements.sleep_feeling.value),
sport_minutes: Number(form.elements.sport_minutes.value || 0), 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), walk_minutes: Number(form.elements.walk_minutes.value || 0),
note: form.elements.note.value || "", note: form.elements.note.value || "",
}); });
const render = () => { const render = () => {
const result = evaluateEntry(collect(), payload.settings); const result = evaluateEntry(collect(), payload.settings, payload.previousEntry || null);
totalNode.textContent = formatNumber(result.total); totalNode.textContent = formatNumber(result.total);
labelNode.textContent = result.label; labelNode.textContent = result.label;
iconNode.src = moodIconPath(result.sentiment); iconNode.src = moodIconPath(result.sentiment);
@@ -369,9 +432,9 @@
const recent = items.slice(-18); const recent = items.slice(-18);
const maxValue = Math.max(...recent.map(item => Number(item.value)), 1); const maxValue = Math.max(...recent.map(item => Number(item.value)), 1);
const width = Math.max(recent.length * 34, 520); const width = Math.max(recent.length * 34, 520);
const height = 184; const height = 194;
const chartHeight = 118; const chartHeight = 116;
const baseY = 146; const baseY = 150;
const bars = recent.map((item, index) => { const bars = recent.map((item, index) => {
const sport = Number(item.sport || 0); const sport = Number(item.sport || 0);
@@ -384,6 +447,21 @@
const backgroundY = baseY - chartHeight; const backgroundY = baseY - chartHeight;
const walkY = baseY - walkHeight; const walkY = baseY - walkHeight;
const sportY = walkY - sportHeight; 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 `
<circle class="bar-icon-ring" cx="${iconX + 9}" cy="${iconY + 9}" r="8.5"></circle>
<image class="bar-icon" href="${icon}" x="${iconX + 1.5}" y="${iconY + 1.5}" width="15" height="15" preserveAspectRatio="xMidYMid meet"></image>
`;
}).join("") + (bonus > 0 ? `<circle class="bar-bonus-dot" cx="${x + 18}" cy="${badgeY - 2}" r="4"></circle>` : "")
: "";
return ` return `
<rect class="bar-grid" x="${x}" y="${backgroundY}" width="18" height="${chartHeight}" rx="9"></rect> <rect class="bar-grid" x="${x}" y="${backgroundY}" width="18" height="${chartHeight}" rx="9"></rect>
@@ -391,10 +469,11 @@
<title>${formatDateLabel(item.date)} · Spaziergang ${walk} min</title> <title>${formatDateLabel(item.date)} · Spaziergang ${walk} min</title>
</rect> </rect>
<rect class="bar-segment--sport" x="${x}" y="${sportY}" width="18" height="${sportHeight}" rx="9"> <rect class="bar-segment--sport" x="${x}" y="${sportY}" width="18" height="${sportHeight}" rx="9">
<title>${formatDateLabel(item.date)} · Sport ${sport} min</title> <title>${formatDateLabel(item.date)} · Sport ${sport} min${label}${bonus > 0 ? ` · Bonus ${formatNumber(bonus)}` : ""}</title>
</rect> </rect>
${iconMarkup}
<text class="bar-value" x="${x + 9}" y="${baseY - chartHeight - 10}">${Math.round(total)}</text> <text class="bar-value" x="${x + 9}" y="${baseY - chartHeight - 10}">${Math.round(total)}</text>
<text class="bar-label" x="${x + 9}" y="172" text-anchor="middle">${formatDateLabel(item.date)}</text> <text class="bar-label" x="${x + 9}" y="180" text-anchor="middle">${formatDateLabel(item.date)}</text>
`; `;
}).join(""); }).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", () => { window.addEventListener("resize", () => {
if (!document.querySelector("#calendar-heatmap")) { if (!document.querySelector("#calendar-heatmap")) {
return; return;
@@ -578,4 +745,5 @@
initHeaderDatePicker(); initHeaderDatePicker();
initTrackPreview(); initTrackPreview();
initDashboardCharts(); initDashboardCharts();
initSportTypeManager();
})(); })();
+66 -22
View File
@@ -184,16 +184,9 @@ final class App
private function showDashboard(): void private function showDashboard(): void
{ {
$user = $this->requireUser(); $user = $this->requireUser();
$settings = $this->settings->forUser($user['username']); $settings = $this->hydrateSettings($this->settings->forUser($user['username']));
$entries = $this->entries->all($user['username']); $entries = $this->entries->all($user['username']);
$evaluatedEntries = $this->evaluateEntriesWithContext($entries, $settings);
$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']));
$summary = $this->buildDashboardSummary($evaluatedEntries); $summary = $this->buildDashboardSummary($evaluatedEntries);
$chartData = $this->buildDashboardCharts($evaluatedEntries); $chartData = $this->buildDashboardCharts($evaluatedEntries);
@@ -211,7 +204,7 @@ final class App
private function showTrack(): void private function showTrack(): void
{ {
$user = $this->requireUser(); $user = $this->requireUser();
$settings = $this->settings->forUser($user['username']); $settings = $this->hydrateSettings($this->settings->forUser($user['username']));
$date = (string) ($_GET['date'] ?? today()); $date = (string) ($_GET['date'] ?? today());
if (!$this->isValidDate($date)) { if (!$this->isValidDate($date)) {
$date = today(); $date = today();
@@ -224,12 +217,15 @@ final class App
'sleep_hours' => 7, 'sleep_hours' => 7,
'sleep_feeling' => 3, 'sleep_feeling' => 3,
'sport_minutes' => 0, 'sport_minutes' => 0,
'sport_type' => '',
'sport_types' => [],
'walk_minutes' => 0, 'walk_minutes' => 0,
'note' => '', 'note' => '',
]; ];
$entry = $this->scoring->normalize($entry); $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', [ View::render('track', [
'pageTitle' => 'Tag tracken', 'pageTitle' => 'Tag tracken',
@@ -238,11 +234,13 @@ final class App
'entry' => $entry, 'entry' => $entry,
'evaluation' => $evaluation, 'evaluation' => $evaluation,
'settings' => $settings, 'settings' => $settings,
'sportTypes' => normalized_sport_types($settings),
'trackMood' => $evaluation['sentiment'], 'trackMood' => $evaluation['sentiment'],
'topbarDate' => $entry['date'], 'topbarDate' => $entry['date'],
'trackPayload' => encode_payload([ 'trackPayload' => encode_payload([
'settings' => $settings, 'settings' => $settings,
'entry' => $entry, 'entry' => $entry,
'previousEntry' => $previousEntry !== null ? $this->scoring->normalize($previousEntry) : null,
]), ]),
]); ]);
} }
@@ -252,7 +250,7 @@ final class App
$this->enforceCsrf(); $this->enforceCsrf();
$user = $this->requireUser(); $user = $this->requireUser();
$settings = $this->settings->forUser($user['username']); $settings = $this->hydrateSettings($this->settings->forUser($user['username']));
$entry = $this->scoring->normalize([ $entry = $this->scoring->normalize([
'date' => $_POST['date'] ?? today(), 'date' => $_POST['date'] ?? today(),
@@ -262,6 +260,7 @@ final class App
'sleep_hours' => $_POST['sleep_hours'] ?? 0, 'sleep_hours' => $_POST['sleep_hours'] ?? 0,
'sleep_feeling' => $_POST['sleep_feeling'] ?? 3, 'sleep_feeling' => $_POST['sleep_feeling'] ?? 3,
'sport_minutes' => $_POST['sport_minutes'] ?? 0, 'sport_minutes' => $_POST['sport_minutes'] ?? 0,
'sport_types' => $_POST['sport_types'] ?? [],
'walk_minutes' => $_POST['walk_minutes'] ?? 0, 'walk_minutes' => $_POST['walk_minutes'] ?? 0,
'note' => $_POST['note'] ?? '', 'note' => $_POST['note'] ?? '',
]); ]);
@@ -271,7 +270,8 @@ final class App
redirect('/track'); 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); $this->entries->save($user['username'], $entry['date'], $entry, $evaluation);
flash('success', 'Der Tag wurde gespeichert.'); flash('success', 'Der Tag wurde gespeichert.');
@@ -281,16 +281,10 @@ final class App
private function showArchive(): void private function showArchive(): void
{ {
$user = $this->requireUser(); $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; $selectedDate = isset($_GET['date']) ? (string) $_GET['date'] : null;
$entries = $this->entries->all($user['username']); $entries = $this->entries->all($user['username']);
$archive = array_reverse($this->evaluateEntriesWithContext($entries, $settings));
$archive = [];
foreach ($entries as $entry) {
$archive[] = array_merge($entry, [
'evaluation' => $this->scoring->evaluate($entry, $settings),
]);
}
$selectedEntry = null; $selectedEntry = null;
if ($selectedDate !== null) { if ($selectedDate !== null) {
@@ -314,7 +308,7 @@ final class App
private function showOptions(): void private function showOptions(): void
{ {
$user = $this->requireUser(); $user = $this->requireUser();
$settings = $this->settings->forUser($user['username']); $settings = $this->hydrateSettings($this->settings->forUser($user['username']));
View::render('options', [ View::render('options', [
'pageTitle' => 'Optionen', 'pageTitle' => 'Optionen',
@@ -329,6 +323,10 @@ final class App
'sleep_hours' => 7, 'sleep_hours' => 7,
'sleep_feeling' => 5, 'sleep_feeling' => 5,
'sport_minutes' => 999, 'sport_minutes' => 999,
'sport_types' => array_map(
static fn (array $type): string => (string) ($type['id'] ?? ''),
normalized_sport_types($settings)
),
'walk_minutes' => 999, 'walk_minutes' => 999,
'note' => 'x', 'note' => 'x',
], $settings)['max_total'], ], $settings)['max_total'],
@@ -467,6 +465,15 @@ final class App
'value' => $entry['sport_minutes'] + $entry['walk_minutes'], 'value' => $entry['sport_minutes'] + $entry['walk_minutes'],
'sport' => $entry['sport_minutes'], 'sport' => $entry['sport_minutes'],
'walk' => $entry['walk_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), }, $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; 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 private function sendSecurityHeaders(): void
{ {
header('Referrer-Policy: strict-origin-when-cross-origin'); header('Referrer-Policy: strict-origin-when-cross-origin');
+26 -1
View File
@@ -62,6 +62,21 @@ final class EntryRepository
private function parse(string $content, string $fallbackDate): ?array 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 = [ $entry = [
'date' => $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate, 'date' => $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate,
'mood' => (int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5), '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_hours' => (float) ($this->extract('/^- Schlafdauer:\s*(.+)$/m', $content) ?? 0),
'sleep_feeling' => (int) ($this->extract('/^- Schlaf(?:gefühl|gefuehl):\s*(.+)$/mu', $content) ?? 3), 'sleep_feeling' => (int) ($this->extract('/^- Schlaf(?:gefühl|gefuehl):\s*(.+)$/mu', $content) ?? 3),
'sport_minutes' => (int) ($this->extract('/^- Sport:\s*(.+)$/m', $content) ?? 0), '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), 'walk_minutes' => (int) ($this->extract('/^- Spaziergang:\s*(.+)$/m', $content) ?? 0),
'note' => $this->extractNote($content), 'note' => $this->extractNote($content),
]; ];
@@ -101,8 +118,14 @@ final class EntryRepository
private function toMarkdown(string $username, string $date, array $entry, array $evaluation): string 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 = [ $lines = [
'<!-- mood-tracker:v1 -->', '<!-- mood-tracker:v2 -->',
'# Stimmungstracker', '# Stimmungstracker',
'Datum: ' . $date, 'Datum: ' . $date,
'Benutzer: ' . normalize_username($username), 'Benutzer: ' . normalize_username($username),
@@ -114,6 +137,7 @@ final class EntryRepository
'- Schlafdauer: ' . $entry['sleep_hours'], '- Schlafdauer: ' . $entry['sleep_hours'],
'- Schlafgefühl: ' . $entry['sleep_feeling'], '- Schlafgefühl: ' . $entry['sleep_feeling'],
'- Sport: ' . $entry['sport_minutes'], '- Sport: ' . $entry['sport_minutes'],
'- Sportarten: ' . implode(', ', $sportTypeValues),
'- Spaziergang: ' . $entry['walk_minutes'], '- Spaziergang: ' . $entry['walk_minutes'],
'', '',
'## Bewertung', '## Bewertung',
@@ -127,6 +151,7 @@ final class EntryRepository
'- Schlafdauer: ' . format_points((float) $evaluation['components']['sleep_hours']), '- Schlafdauer: ' . format_points((float) $evaluation['components']['sleep_hours']),
'- Schlafgefühl: ' . format_points((float) $evaluation['components']['sleep_feeling']), '- Schlafgefühl: ' . format_points((float) $evaluation['components']['sleep_feeling']),
'- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']), '- 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']), '- Spaziergang: ' . format_points((float) $evaluation['components']['walk_minutes']),
'- Notiz: ' . format_points((float) $evaluation['components']['note']), '- Notiz: ' . format_points((float) $evaluation['components']['note']),
'', '',
+92 -1
View File
@@ -6,6 +6,8 @@ final class ScoringService
{ {
public function normalize(array $input): array public function normalize(array $input): array
{ {
$sportTypes = normalize_sport_type_selection($input['sport_types'] ?? ($input['sport_type'] ?? []));
return [ return [
'date' => $input['date'] ?? today(), 'date' => $input['date'] ?? today(),
'mood' => max(1, min(10, (int) ($input['mood'] ?? 5))), '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_hours' => max(0, min(24, (float) ($input['sleep_hours'] ?? 0))),
'sleep_feeling' => max(1, min(5, (int) ($input['sleep_feeling'] ?? 3))), 'sleep_feeling' => max(1, min(5, (int) ($input['sleep_feeling'] ?? 3))),
'sport_minutes' => max(0, min(1440, (int) ($input['sport_minutes'] ?? 0))), '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))), 'walk_minutes' => max(0, min(1440, (int) ($input['walk_minutes'] ?? 0))),
'note' => trim((string) ($input['note'] ?? '')), '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); $entry = $this->normalize($entry);
$previousEntry = $previousEntry !== null ? $this->normalize($previousEntry) : null;
$scoring = $settings['scoring']; $scoring = $settings['scoring'];
$ratings = $this->sortedRatings($settings['ratings'] ?? []); $ratings = $this->sortedRatings($settings['ratings'] ?? []);
$sportTypes = find_sport_types($settings, $entry['sport_types']);
$sportBonus = $this->sportBonusPoints($entry, $previousEntry, $settings, $sportTypes);
$components = [ $components = [
'mood' => $entry['mood'] * (float) $scoring['mood_multiplier'], '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_hours' => $this->sleepDurationPoints((float) $entry['sleep_hours'], $scoring['sleep_duration_points']),
'sleep_feeling' => $entry['sleep_feeling'] * (float) $scoring['sleep_feeling_multiplier'], 'sleep_feeling' => $entry['sleep_feeling'] * (float) $scoring['sleep_feeling_multiplier'],
'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']), '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']), 'walk_minutes' => $this->bandPoints((int) $entry['walk_minutes'], $scoring['walk_bands']),
'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'], 'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'],
]; ];
@@ -44,6 +52,7 @@ final class ScoringService
max(array_map('floatval', $scoring['sleep_duration_points'])) + max(array_map('floatval', $scoring['sleep_duration_points'])) +
(5 * (float) $scoring['sleep_feeling_multiplier']) + (5 * (float) $scoring['sleep_feeling_multiplier']) +
$this->maxBandPoints($scoring['sport_bands']) + $this->maxBandPoints($scoring['sport_bands']) +
$this->maxSportBonusPoints($settings) +
$this->maxBandPoints($scoring['walk_bands']) + $this->maxBandPoints($scoring['walk_bands']) +
(float) $scoring['journal_points'], (float) $scoring['journal_points'],
1 1
@@ -74,6 +83,8 @@ final class ScoringService
'guardrail' => $guardrail, 'guardrail' => $guardrail,
'sentiment' => $this->sentimentForLabel($label, $ratings), 'sentiment' => $this->sentimentForLabel($label, $ratings),
'percentage' => $maxTotal > 0 ? round(($total / $maxTotal) * 100, 1) : 0, '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; 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 private function sortedRatings(array $ratings): array
{ {
usort($ratings, static fn (array $a, array $b): int => ((int) $a['min']) <=> ((int) $b['min'])); 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); $path = $this->pathFor($username);
$saved = decode_json_file($path, []); $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 public function saveForUser(string $username, array $settings): void
@@ -32,4 +37,3 @@ final class SettingsRepository
return storage_path('users/' . normalize_username($username) . '/settings.json'); return storage_path('users/' . normalize_username($username) . '/settings.json');
} }
} }
+42
View File
@@ -16,6 +16,48 @@ final class Defaults
5 => 'sehr ausgeschlafen', 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' => [ 'scoring' => [
'mood_multiplier' => 3, 'mood_multiplier' => 3,
'energy_multiplier' => 2, 'energy_multiplier' => 2,
+159
View File
@@ -175,3 +175,162 @@ function mood_icon_path(string $sentiment): string
{ {
return icon_path('mood-' . $sentiment); 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;
}
+28
View File
@@ -17,6 +17,16 @@
<div> <div>
<strong><?= e(format_display_date($entry['date'], false)) ?></strong> <strong><?= e(format_display_date($entry['date'], false)) ?></strong>
<span><?= e($entry['evaluation']['label']) ?></span> <span><?= e($entry['evaluation']['label']) ?></span>
<?php if ((int) $entry['sport_minutes'] > 0 && !empty($entry['sport_type_meta'])): ?>
<span class="sport-pill-group">
<?php foreach ($entry['sport_type_meta'] as $sportType): ?>
<span class="sport-pill">
<img src="<?= e(sport_icon_path($sportType['icon'])) ?>" alt="">
<span><?= e($sportType['label']) ?></span>
</span>
<?php endforeach; ?>
</span>
<?php endif; ?>
</div> </div>
<div class="archive-item__meta"> <div class="archive-item__meta">
<span><?= e(format_points((float) $entry['evaluation']['total'])) ?></span> <span><?= e(format_points((float) $entry['evaluation']['total'])) ?></span>
@@ -47,6 +57,24 @@
<div><dt>Schlaf</dt><dd><?= e((string) $selectedEntry['sleep_hours']) ?> h</dd></div> <div><dt>Schlaf</dt><dd><?= e((string) $selectedEntry['sleep_hours']) ?> h</dd></div>
<div><dt>Schlafgefühl</dt><dd><?= e((string) $selectedEntry['sleep_feeling']) ?>/5</dd></div> <div><dt>Schlafgefühl</dt><dd><?= e((string) $selectedEntry['sleep_feeling']) ?>/5</dd></div>
<div><dt>Sport</dt><dd><?= e((string) $selectedEntry['sport_minutes']) ?> min</dd></div> <div><dt>Sport</dt><dd><?= e((string) $selectedEntry['sport_minutes']) ?> min</dd></div>
<div>
<dt>Sportarten</dt>
<dd>
<?php if ((int) $selectedEntry['sport_minutes'] > 0 && !empty($selectedEntry['sport_type_meta'])): ?>
<span class="sport-pill-group sport-pill-group--inline">
<?php foreach ($selectedEntry['sport_type_meta'] as $sportType): ?>
<span class="sport-pill sport-pill--inline">
<img src="<?= e(sport_icon_path($sportType['icon'])) ?>" alt="">
<span><?= e($sportType['label']) ?></span>
</span>
<?php endforeach; ?>
</span>
<?php else: ?>
keine
<?php endif; ?>
</dd>
</div>
<div><dt>Sportbonus</dt><dd><?= e(format_points((float) ($selectedEntry['evaluation']['components']['sport_bonus'] ?? 0))) ?></dd></div>
<div><dt>Spaziergang</dt><dd><?= e((string) $selectedEntry['walk_minutes']) ?> min</dd></div> <div><dt>Spaziergang</dt><dd><?= e((string) $selectedEntry['walk_minutes']) ?> min</dd></div>
</dl> </dl>
+102
View File
@@ -60,6 +60,108 @@
</div> </div>
</div> </div>
<div class="settings-section">
<div class="section-head section-head--compact">
<div>
<h4>Sportarten und Bonuspunkte</h4>
<p class="helper-text">Lege fest, welche Sportarten im Tracking auswählbar sind. Der Bonus gilt nur, wenn am Vortag keine gleiche Erholungsgruppe trainiert wurde.</p>
</div>
<button class="ghost-button" type="button" data-add-sport-type>Sportart hinzufügen</button>
</div>
<div class="sport-type-list" data-sport-type-list>
<?php foreach ($settings['sport_types'] as $index => $sportType): ?>
<div class="sport-type-card band-card" data-sport-type-row>
<input type="hidden" name="settings[sport_types][<?= e((string) $index) ?>][id]" value="<?= e($sportType['id']) ?>" data-name-template="settings[sport_types][__INDEX__][id]">
<div class="field-grid field-grid--four">
<label>
<span>Bezeichnung</span>
<input type="text" name="settings[sport_types][<?= e((string) $index) ?>][label]" value="<?= e($sportType['label']) ?>" data-name-template="settings[sport_types][__INDEX__][label]">
</label>
<label>
<span>Icon</span>
<select name="settings[sport_types][<?= e((string) $index) ?>][icon]" data-name-template="settings[sport_types][__INDEX__][icon]">
<?php foreach (sport_icon_options() as $iconValue => $iconLabel): ?>
<option value="<?= e($iconValue) ?>" <?= $sportType['icon'] === $iconValue ? 'selected' : '' ?>><?= e($iconLabel) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
<span>Erholungsgruppe</span>
<input type="text" name="settings[sport_types][<?= e((string) $index) ?>][recovery_group]" value="<?= e($sportType['recovery_group']) ?>" data-name-template="settings[sport_types][__INDEX__][recovery_group]">
</label>
<label>
<span>Bonuspunkte</span>
<input type="number" name="settings[sport_types][<?= e((string) $index) ?>][bonus_points]" value="<?= e((string) $sportType['bonus_points']) ?>" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]">
</label>
</div>
<label class="checkbox-row">
<input type="checkbox" name="settings[sport_types][<?= e((string) $index) ?>][allow_consecutive]" value="1" <?= !empty($sportType['allow_consecutive']) ? 'checked' : '' ?> data-name-template="settings[sport_types][__INDEX__][allow_consecutive]">
<span>Darf an aufeinanderfolgenden Tagen Bonus geben</span>
</label>
<div class="sport-type-card__actions">
<span class="sport-pill sport-pill--soft">
<img src="<?= e(sport_icon_path($sportType['icon'])) ?>" alt="">
<span><?= e($sportType['label']) ?></span>
</span>
<button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button>
</div>
</div>
<?php endforeach; ?>
</div>
<template id="sport-type-row-template">
<div class="sport-type-card band-card" data-sport-type-row>
<input type="hidden" value="" data-name-template="settings[sport_types][__INDEX__][id]">
<div class="field-grid field-grid--four">
<label>
<span>Bezeichnung</span>
<input type="text" value="" placeholder="z. B. Mobility" data-name-template="settings[sport_types][__INDEX__][label]">
</label>
<label>
<span>Icon</span>
<select data-name-template="settings[sport_types][__INDEX__][icon]">
<?php foreach (sport_icon_options() as $iconValue => $iconLabel): ?>
<option value="<?= e($iconValue) ?>"><?= e($iconLabel) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
<span>Erholungsgruppe</span>
<input type="text" value="" placeholder="z. B. kraftsport" data-name-template="settings[sport_types][__INDEX__][recovery_group]">
</label>
<label>
<span>Bonuspunkte</span>
<input type="number" value="2" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]">
</label>
</div>
<label class="checkbox-row">
<input type="checkbox" value="1" data-name-template="settings[sport_types][__INDEX__][allow_consecutive]">
<span>Darf an aufeinanderfolgenden Tagen Bonus geben</span>
</label>
<div class="sport-type-card__actions">
<span class="sport-pill sport-pill--soft">
<img src="<?= e(sport_icon_path('run')) ?>" alt="">
<span>Neue Sportart</span>
</span>
<button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button>
</div>
</div>
</template>
</div>
<div class="settings-section"> <div class="settings-section">
<h4>Bewertungsskala</h4> <h4>Bewertungsskala</h4>
<div class="band-grid"> <div class="band-grid">
+20 -1
View File
@@ -11,7 +11,7 @@
</div> </div>
</div> </div>
<form method="post" action="/track" class="tracker-form" id="tracker-form"> <form method="post" action="/track" class="tracker-form" id="tracker-form" autocomplete="off">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="date" value="<?= e($entry['date']) ?>"> <input type="hidden" name="date" value="<?= e($entry['date']) ?>">
@@ -65,6 +65,24 @@
</label> </label>
</div> </div>
<div class="field-grid field-grid--single">
<fieldset class="sport-choice-field">
<legend>Sportarten</legend>
<div class="sport-choice-list">
<?php foreach ($sportTypes as $sportType): ?>
<?php $isChecked = in_array($sportType['id'], $entry['sport_types'], true); ?>
<label class="sport-choice">
<input type="checkbox" name="sport_types[]" value="<?= e($sportType['id']) ?>" <?= $isChecked ? 'checked' : '' ?>>
<span class="sport-choice__card">
<img src="<?= e(sport_icon_path($sportType['icon'])) ?>" alt="">
<strong><?= e($sportType['label']) ?></strong>
</span>
</label>
<?php endforeach; ?>
</div>
</fieldset>
</div>
<label> <label>
<span>Tagebuchnotiz</span> <span>Tagebuchnotiz</span>
<textarea name="note" rows="8" placeholder="Was war heute wichtig, schwer oder schön?"><?= e($entry['note']) ?></textarea> <textarea name="note" rows="8" placeholder="Was war heute wichtig, schwer oder schön?"><?= e($entry['note']) ?></textarea>
@@ -99,6 +117,7 @@
'sleep_hours' => 'Schlafdauer', 'sleep_hours' => 'Schlafdauer',
'sleep_feeling' => 'Schlafgefühl', 'sleep_feeling' => 'Schlafgefühl',
'sport_minutes' => 'Sport', 'sport_minutes' => 'Sport',
'sport_bonus' => 'Sportbonus',
'walk_minutes' => 'Spaziergang', 'walk_minutes' => 'Spaziergang',
'note' => 'Notiz', 'note' => 'Notiz',
]; ];