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
+175 -7
View File
@@ -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 `
<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 `
<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>
</rect>
<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>
${iconMarkup}
<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("");
@@ -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();
})();