(function () { const textDecoder = new TextDecoder(); let dashboardResizeTimer = null; function decodePayload(raw) { if (!raw) { return null; } const bytes = Uint8Array.from(atob(raw), char => char.charCodeAt(0)); return JSON.parse(textDecoder.decode(bytes)); } function formatNumber(value) { const rounded = Math.round(value * 10) / 10; return Number.isInteger(rounded) ? String(rounded) : rounded.toLocaleString("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 1 }); } function formatDateLabel(value) { const [year, month, day] = value.split("-"); return `${day}.${month}.`; } function toLocalIso(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } function getCssVar(name, fallback) { const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return value || fallback; } function moodIconPath(sentiment) { 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}"]`); if (!input) { return; } const sync = () => { output.textContent = input.value; }; sync(); input.addEventListener("input", sync); }); } function initHeaderDatePicker() { document.querySelectorAll(".topbar-date-input").forEach(input => { input.addEventListener("change", () => { const form = input.closest("form"); if (form) { form.submit(); } }); }); } function sleepDurationPoints(hours, points) { if (hours < 4) { return Number(points.lt4 || 0); } if (hours >= 10) { return Number(points.h10plus || 0); } const anchors = { 4: Number(points.h4 || 0), 5: Number(points.h5 || 0), 6: Number(points.h6 || 0), 7: Number(points.h7 || 0), 8: Number(points.h8 || 0), 9: Number(points.h9 || 0), 10: Number(points.h10plus || 0), }; const lower = Math.floor(hours); const upper = Math.ceil(hours); if (lower === upper) { return anchors[lower] || 0; } const fraction = hours - lower; const lowerPoints = anchors[lower] || 0; const upperPoints = anchors[upper] || 0; return Math.round((lowerPoints + ((upperPoints - lowerPoints) * fraction)) * 10) / 10; } function bandPoints(value, bands) { for (const band of bands || []) { if (value >= Number(band.min || 0) && value <= Number(band.max || 0)) { return Number(band.points || 0); } } const last = (bands || []).slice(-1)[0]; return last ? Number(last.points || 0) : 0; } function sortedRatings(ratings) { 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)) { return rating.label; } } if (!ratings.length) { return "unbewertet"; } return score < Number(ratings[0].min || 0) ? ratings[0].label : ratings[ratings.length - 1].label; } function capLabel(current, cap, ratings) { const order = ratings.map(item => item.label); const currentIndex = order.indexOf(current); const capIndex = order.indexOf(cap); if (currentIndex === -1 || capIndex === -1) { return current; } return currentIndex > capIndex ? cap : current; } function sentimentForLabel(label, ratings) { const order = ratings.map(item => item.label); const index = order.indexOf(label); if (index === -1 || order.length <= 1) { return "steady"; } const ratio = index / Math.max(order.length - 1, 1); if (ratio <= 0.1) { return "storm"; } if (ratio <= 0.35) { return "heavy"; } if (ratio <= 0.65) { return "steady"; } if (ratio <= 0.9) { return "bright"; } return "radiant"; } function evaluateEntry(entry, settings, previousEntry = null) { const ratings = sortedRatings(settings.ratings || []); const scoring = settings.scoring || {}; const components = { mood: Number(entry.mood) * Number(scoring.mood_multiplier || 0), energy: Number(entry.energy) * Number(scoring.energy_multiplier || 0), stress: (11 - Number(entry.stress)) * Number(scoring.stress_multiplier || 0), 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), }; const total = Math.round(Object.values(components).reduce((sum, value) => sum + Number(value), 0) * 10) / 10; let label = labelForScore(total, ratings); for (const guardrail of settings.guardrails || []) { const moodMatch = Number(entry.mood) <= Number(guardrail.mood_max || 10); const energyMatch = guardrail.energy_max === null || guardrail.energy_max === "" ? true : Number(entry.energy) <= Number(guardrail.energy_max); if (moodMatch && energyMatch) { label = capLabel(label, guardrail.cap_label, ratings); } } return { total, label, components, sentiment: sentimentForLabel(label, ratings) }; } function initTrackPreview() { const card = document.querySelector("#live-score-card"); const form = document.querySelector("#tracker-form"); if (!card || !form) { return; } const payload = decodePayload(card.dataset.payload); if (!payload) { return; } const totalNode = card.querySelector("[data-preview-total]"); const labelNode = card.querySelector("[data-preview-label]"); const iconNode = card.querySelector("[data-preview-icon]"); const componentsNode = card.querySelector("[data-preview-components]"); const componentLabels = { mood: "Stimmung", energy: "Energie", stress: "Stress", sleep_hours: "Schlafdauer", sleep_feeling: "Schlafgefühl", sport_minutes: "Sport", sport_bonus: "Sportbonus", walk_minutes: "Spaziergang", note: "Notiz", }; const collect = () => ({ mood: Number(form.elements.mood.value), energy: Number(form.elements.energy.value), stress: Number(form.elements.stress.value), 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, payload.previousEntry || null); totalNode.textContent = formatNumber(result.total); labelNode.textContent = result.label; iconNode.src = moodIconPath(result.sentiment); card.dataset.sentiment = result.sentiment; document.body.dataset.trackMood = result.sentiment; componentsNode.innerHTML = Object.entries(result.components).map(([key, value]) => { return `
${componentLabels[key] || key}
${formatNumber(Number(value))}
`; }).join(""); }; render(); form.addEventListener("input", render); form.addEventListener("change", render); } function emptyState(message) { const wrapper = document.createElement("div"); wrapper.className = "empty-state"; wrapper.textContent = message; return wrapper; } function linePath(points) { if (!points.length) { return ""; } let path = `M ${points[0].x} ${points[0].y}`; for (let index = 1; index < points.length; index += 1) { const previous = points[index - 1]; const current = points[index]; const midX = (previous.x + current.x) / 2; path += ` Q ${midX} ${previous.y}, ${current.x} ${current.y}`; } return path; } function renderLineChart(container, items, color) { if (!items || !items.length) { container.append(emptyState("Noch nicht genug Daten für diesen Verlauf.")); return; } const seriesName = container.dataset.series || ""; const values = items.map(item => Number(item.value)); const width = 760; const height = 196; const padding = { top: 10, right: 18, bottom: 28, left: 14 }; let minValue = Math.min(...values); let maxValue = Math.max(...values); if (seriesName === "mood" || seriesName === "stress") { minValue = Math.max(1, minValue - 1.5); maxValue = Math.min(10, maxValue + 1.5); } else { minValue = Math.max(0, minValue - 1); maxValue = maxValue + 1; } if ((maxValue - minValue) < 3) { const center = (maxValue + minValue) / 2; if (seriesName === "mood" || seriesName === "stress") { minValue = Math.max(1, center - 1.5); maxValue = Math.min(10, center + 1.5); } else { minValue = Math.max(0, center - 1.5); maxValue = center + 1.5; } } if (items.length === 1) { const only = items[0]; const chartTop = padding.top; const chartBottom = height - padding.bottom; const ratio = (Number(only.value) - minValue) / Math.max(maxValue - minValue, 1); const cx = width / 2; const cy = chartTop + ((1 - ratio) * (chartBottom - chartTop)); container.innerHTML = ` ${formatNumber(Number(only.value))} ${formatDateLabel(only.date)} ${formatDateLabel(only.date)}: ${formatNumber(Number(only.value))} `; return; } const step = items.length > 1 ? (width - padding.left - padding.right) / (items.length - 1) : 0; const points = items.map((item, index) => { const ratio = (Number(item.value) - minValue) / Math.max(maxValue - minValue, 1); return { x: padding.left + (index * step), y: padding.top + ((1 - ratio) * (height - padding.top - padding.bottom)), label: formatDateLabel(item.date), value: Number(item.value), }; }); const path = linePath(points); const fillPath = `${path} L ${points[points.length - 1].x} ${height - padding.bottom} L ${points[0].x} ${height - padding.bottom} Z`; const labels = points.filter((_, index) => index === 0 || index === points.length - 1 || index % Math.ceil(points.length / 5) === 0); container.innerHTML = ` ${points.map(point => `${point.label}: ${formatNumber(point.value)}`).join("")} ${labels.map(point => `${point.label}`).join("")} `; } function renderBarChart(container, items) { if (!items || !items.length) { container.append(emptyState("Sobald Sport- oder Gehwerte vorhanden sind, erscheint hier die Entwicklung.")); return; } 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 = 194; const chartHeight = 116; const baseY = 150; const bars = recent.map((item, index) => { const sport = Number(item.sport || 0); const walk = Number(item.walk || 0); const total = sport + walk; const x = 18 + (index * 34); const columnHeight = total > 0 ? Math.max((total / maxValue) * chartHeight, 8) : 0; const walkHeight = total > 0 ? (walk / total) * columnHeight : 0; const sportHeight = total > 0 ? (sport / total) * columnHeight : 0; 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 ` ${formatDateLabel(item.date)} · Spaziergang ${walk} min ${formatDateLabel(item.date)} · Sport ${sport} min${label}${bonus > 0 ? ` · Bonus ${formatNumber(bonus)}` : ""} ${iconMarkup} ${Math.round(total)} ${formatDateLabel(item.date)} `; }).join(""); container.innerHTML = ` ${bars} `; } function renderCalendar(container, items) { if (!items || !items.length) { container.append(emptyState("Der Kalender füllt sich automatisch mit den gespeicherten Tagen.")); return; } function calendarColor(entry) { if (!entry) { return "rgba(255, 255, 255, 0.06)"; } const ratio = Math.max(0, Math.min(1, Number(entry.score) / Math.max(Number(entry.max || 1), 1))); if (ratio <= 0.36) { return `rgba(255, 117, 117, ${0.28 + (ratio * 0.8)})`; } if (ratio <= 0.52) { return `rgba(255, 171, 122, ${0.28 + (ratio * 0.7)})`; } if (ratio <= 0.68) { return `rgba(132, 193, 208, ${0.24 + (ratio * 0.55)})`; } if (ratio <= 0.84) { return `rgba(116, 226, 183, ${0.28 + (ratio * 0.6)})`; } return `rgba(112, 240, 182, ${0.38 + (ratio * 0.52)})`; } const map = new Map(items.map(item => [item.date, item])); const end = new Date(); const start = new Date(end); start.setDate(end.getDate() - 364); const gridStart = new Date(start); const startWeekday = gridStart.getDay(); const startOffset = startWeekday === 0 ? 6 : startWeekday - 1; gridStart.setDate(gridStart.getDate() - startOffset); const gridEnd = new Date(end); const endWeekday = gridEnd.getDay(); const endOffset = endWeekday === 0 ? 0 : 7 - endWeekday; gridEnd.setDate(gridEnd.getDate() + endOffset); const days = []; const cursor = new Date(gridStart); while (cursor <= gridEnd) { const iso = toLocalIso(cursor); const entry = map.get(iso) || null; days.push({ date: iso, entry, weekday: cursor.getDay(), month: cursor.getMonth(), day: cursor.getDate(), }); cursor.setDate(cursor.getDate() + 1); } const totalWeeks = Math.floor((days.length - 1) / 7) + 1; const cellSize = 12; const baseCellGap = 5; const verticalGap = 5; const xOffset = 34; const yOffset = 22; const gridHeight = (7 * cellSize) + (6 * verticalGap); const height = yOffset + gridHeight + 8; const rightPadding = 4; const naturalWidth = xOffset + (totalWeeks * cellSize) + ((totalWeeks - 1) * baseCellGap) + rightPadding; const availableWidth = Math.floor(container.clientWidth || 0); const width = Math.max(naturalWidth, availableWidth); const horizontalGap = totalWeeks > 1 ? (width - xOffset - rightPadding - (totalWeeks * cellSize)) / (totalWeeks - 1) : 0; const monthLabels = []; let lastMonth = -1; const cells = days.map((item, index) => { const week = Math.floor(index / 7); const row = item.weekday === 0 ? 6 : item.weekday - 1; const x = xOffset + (week * (cellSize + horizontalGap)); const y = yOffset + (row * (cellSize + verticalGap)); const fill = calendarColor(item.entry); if (item.day <= 7 && item.month !== lastMonth && item.entry !== null) { monthLabels.push({ x, label: new Intl.DateTimeFormat("de-DE", { month: "short" }).format(new Date(`${item.date}T12:00:00`)), }); lastMonth = item.month; } const title = item.entry ? `${item.date}: ${formatNumber(Number(item.entry.score))} Punkte · ${item.entry.label}` : `${item.date}: kein Eintrag`; if (!item.entry) { return `${title}`; } return ` ${title} `; }).join(""); container.innerHTML = ` ${monthLabels.map(item => `${item.label}`).join("")} Mo Mi Fr ${cells}
schlecht
gut
`; } function initDashboardCharts() { const calendar = document.querySelector("#calendar-heatmap"); if (calendar) { const payload = decodePayload(calendar.dataset.payload); if (payload) { renderCalendar(calendar, payload.calendar || []); } } document.querySelectorAll(".line-chart[data-chart-type='line']").forEach(chart => { const payload = decodePayload(chart.dataset.payload); const seriesName = chart.dataset.series; const color = seriesName === "stress" ? getCssVar("--warm", "#ffbf8d") : getCssVar("--primary-strong", "#3cc7ff"); renderLineChart(chart, payload ? payload[seriesName] || [] : [], color); }); document.querySelectorAll(".bar-chart[data-chart-type='bars']").forEach(chart => { const payload = decodePayload(chart.dataset.payload); renderBarChart(chart, payload ? payload.sport || [] : []); }); } 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; } window.clearTimeout(dashboardResizeTimer); dashboardResizeTimer = window.setTimeout(() => { initDashboardCharts(); }, 120); }); updateRangeOutputs(); initHeaderDatePicker(); initTrackPreview(); initDashboardCharts(); initSportTypeManager(); })();