(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 dashboardDayPath(date) { return `/?view=day&date=${encodeURIComponent(date)}`; } 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 initArchiveMobileDetail() { if (!document.body.classList.contains("page-archive")) { return; } const isMobileViewport = () => window.matchMedia("(max-width: 820px)").matches; const detail = document.querySelector("#archive-detail-panel[data-detail-open='1']"); if (!detail || !isMobileViewport()) { return; } requestAnimationFrame(() => { requestAnimationFrame(() => { detail.scrollIntoView({ block: "start", behavior: "smooth" }); }); }); } 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 stepTargetPoints(value, targets) { const list = [...(targets || [])].sort((a, b) => Number(a.steps || 0) - Number(b.steps || 0)); if (!list.length) { return 0; } if (value <= Number(list[0].steps || 0)) { return Number(list[0].points || 0); } const last = list[list.length - 1]; if (value >= Number(last.steps || 0)) { return Number(last.points || 0); } for (let index = 1; index < list.length; index += 1) { const previous = list[index - 1]; const current = list[index]; const previousSteps = Number(previous.steps || 0); const currentSteps = Number(current.steps || 0); if (value > currentSteps) { continue; } const ratio = (value - previousSteps) / Math.max(currentSteps - previousSteps, 1); return Math.round((Number(previous.points || 0) + ((Number(current.points || 0) - Number(previous.points || 0)) * ratio)) * 10) / 10; } return 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 walkMode = entry.walk_mode === "steps" ? "steps" : "time"; const painEnabled = Boolean(settings.tracking?.pain_enabled); 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: walkMode === "steps" ? stepTargetPoints(Number(entry.walk_steps || 0), scoring.walk_step_targets || []) : bandPoints(Number(entry.walk_minutes), scoring.walk_bands || []), alcohol: entry.alcohol ? (Number(scoring.alcohol_penalty || 5) * -1) : 0, note: String(entry.note || "").trim() === "" ? 0 : Number(scoring.journal_points || 0), }; if (painEnabled) { components.pain = (11 - Number(entry.pain || 1)) * Number(scoring.pain_multiplier || 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", pain: "Schmerzen", sleep_hours: "Schlafdauer", sleep_feeling: "Schlafgefühl", sport_minutes: "Sport", sport_bonus: "Sportbonus", walk_minutes: "Spaziergang", alcohol: "Alkohol", note: "Notiz", }; const collect = () => ({ mood: Number(form.elements.mood.value), energy: Number(form.elements.energy.value), stress: Number(form.elements.stress.value), pain: Number(form.elements.pain?.value || 1), 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_mode: form.elements.walk_mode?.value || "time", walk_minutes: Number(form.elements.walk_minutes?.value || 0), walk_steps: Number(form.elements.walk_steps?.value || 0), alcohol: Boolean(form.elements.alcohol?.checked), 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 invertScale = seriesName === "stress" || seriesName === "pain"; 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 === "balance") { minValue = -2; maxValue = 2; } else if (seriesName === "mood" || seriesName === "stress" || seriesName === "pain") { 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 === "balance") { minValue = -2; maxValue = 2; } else if (seriesName === "mood" || seriesName === "stress" || seriesName === "pain") { 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 baseRatio = (Number(only.value) - minValue) / Math.max(maxValue - minValue, 1); const ratio = invertScale ? (1 - baseRatio) : baseRatio; 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 baseRatio = (Number(item.value) - minValue) / Math.max(maxValue - minValue, 1); const ratio = invertScale ? (1 - baseRatio) : baseRatio; 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 walkLabel = item.walk_label || `${walk} Aktivität`; 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 ${walkLabel} ${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) { const isLightMode = window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches; if (!entry) { return isLightMode ? "rgba(86, 124, 156, 0.11)" : "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)})`; } function calendarRangeConfig() { const viewport = window.innerWidth || container.clientWidth || 1200; if (viewport <= 640) { return { rangeDays: 119, label: "letzte 4 Monate", cellSize: 14, columnGap: 6, rowGap: 6, xOffset: 36, yOffset: 24, }; } if (viewport <= 980) { return { rangeDays: 181, label: "letzte 6 Monate", cellSize: 13, columnGap: 5, rowGap: 5, xOffset: 36, yOffset: 24, }; } return { rangeDays: 364, label: "letzte 12 Monate", cellSize: 12, columnGap: 5, rowGap: 5, xOffset: 34, yOffset: 22, }; } const map = new Map(items.map(item => [item.date, item])); const config = calendarRangeConfig(); const end = new Date(); const start = new Date(end); start.setDate(end.getDate() - config.rangeDays); 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 = config.cellSize; const baseCellGap = config.columnGap; const verticalGap = config.rowGap; const xOffset = config.xOffset; const yOffset = config.yOffset; const gridHeight = (7 * cellSize) + (6 * verticalGap); const height = yOffset + gridHeight + 8; const sundayLabelY = yOffset + (6 * (cellSize + verticalGap)) + (cellSize * 0.78); 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 visibleEntries = days.filter(item => item.entry !== null); const latestVisibleEntry = visibleEntries.length ? visibleEntries[visibleEntries.length - 1].entry : null; let activeDate = latestVisibleEntry ? latestVisibleEntry.date : null; 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) { 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))}% Bilanz · ${item.entry.label}` : `${item.date}: kein Eintrag`; if (!item.entry) { return `${title}`; } return ` ${title} `; }).join(""); const detailMarkup = latestVisibleEntry === null ? `
${config.label} Noch kein getrackter Tag Sobald du Einträge speicherst, kannst du sie hier direkt auswählen.
` : `
${config.label} ${formatDateLabel(latestVisibleEntry.date)} ${latestVisibleEntry.label}
${formatNumber(Number(latestVisibleEntry.score))} % Bilanz
Tag öffnen `; container.innerHTML = `
${detailMarkup}
${monthLabels.map(item => `${item.label}`).join("")} Mo Mi Fr So ${cells}
schlecht
gut
`; const detailDate = container.querySelector("[data-calendar-date]"); const detailLabel = container.querySelector("[data-calendar-label]"); const detailScore = container.querySelector("[data-calendar-score]"); const detailLink = container.querySelector("[data-calendar-link]"); const setActive = date => { const entry = map.get(date); if (!entry || !detailDate || !detailLabel || !detailScore || !detailLink) { return; } activeDate = date; detailDate.textContent = formatDateLabel(entry.date); detailLabel.textContent = entry.label; detailScore.textContent = formatNumber(Number(entry.score)); detailLink.href = dashboardDayPath(entry.date); container.querySelectorAll(".calendar-cell--selected").forEach(cell => { cell.classList.remove("calendar-cell--selected"); }); const activeLink = container.querySelector(`.calendar-link[data-date="${date}"] .calendar-cell`); if (activeLink) { activeLink.classList.add("calendar-cell--selected"); } }; container.querySelectorAll(".calendar-link[data-date]").forEach(link => { const date = link.dataset.date; if (!date) { return; } link.addEventListener("mouseenter", () => setActive(date)); link.addEventListener("focus", () => setActive(date)); }); } 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"); const presetButtons = [...document.querySelectorAll("[data-sport-preset]")]; if (!list || !addButton || !template) { return; } const getRows = () => [...list.querySelectorAll("[data-sport-type-row]")]; const syncPreview = row => { const labelInput = row.querySelector('input[data-name-template$="[label]"]'); const iconSelect = row.querySelector('select[data-name-template$="[icon]"]'); const locationSelect = row.querySelector('select[data-name-template$="[location]"]'); const previewText = row.querySelector(".sport-pill span:last-child"); const previewImage = row.querySelector(".sport-pill img"); if (previewText) { const label = (labelInput && labelInput.value.trim()) || "Neue Sportart"; const location = locationSelect && locationSelect.value ? locationSelect.options[locationSelect.selectedIndex]?.textContent || "" : ""; previewText.textContent = location ? `${label} · ${location}` : label; } 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); }); }; const syncPresets = () => { const selectedIds = new Set( getRows() .map(row => row.querySelector('input[data-name-template$="[id]"]')) .filter(Boolean) .map(input => String(input.value || "").trim()) .filter(Boolean) ); presetButtons.forEach(button => { button.hidden = selectedIds.has(button.dataset.id || ""); }); }; const createRow = values => { list.append(template.content.cloneNode(true)); const row = list.querySelector("[data-sport-type-row]:last-child"); if (!row) { return; } const idInput = row.querySelector('input[data-name-template$="[id]"]'); const labelInput = row.querySelector('input[data-name-template$="[label]"]'); const iconSelect = row.querySelector('select[data-name-template$="[icon]"]'); const locationSelect = row.querySelector('select[data-name-template$="[location]"]'); const groupInput = row.querySelector('input[data-name-template$="[recovery_group]"]'); const bonusInput = row.querySelector('input[data-name-template$="[bonus_points]"]'); const consecutiveInput = row.querySelector('input[data-name-template$="[allow_consecutive]"]'); if (idInput) { idInput.value = values.id || ""; } if (labelInput) { labelInput.value = values.label || ""; } if (iconSelect) { iconSelect.value = values.icon || "run"; } if (locationSelect) { locationSelect.value = values.location || ""; } if (groupInput) { groupInput.value = values.recovery_group || ""; } if (bonusInput) { bonusInput.value = values.bonus_points || "2"; } if (consecutiveInput) { consecutiveInput.checked = Boolean(values.allow_consecutive); } renumber(); syncPresets(); }; addButton.addEventListener("click", () => { createRow({}); }); presetButtons.forEach(button => { button.addEventListener("click", () => { createRow({ id: button.dataset.id || "", label: button.dataset.label || "", icon: button.dataset.icon || "run", location: button.dataset.location || "", recovery_group: button.dataset.recoveryGroup || "", bonus_points: button.dataset.bonusPoints || "2", allow_consecutive: button.dataset.allowConsecutive === "1", }); }); }); 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; } row.remove(); renumber(); syncPresets(); }); list.addEventListener("input", event => { const row = event.target.closest("[data-sport-type-row]"); if (row) { syncPreview(row); syncPresets(); } }); list.addEventListener("change", event => { const row = event.target.closest("[data-sport-type-row]"); if (row) { syncPreview(row); syncPresets(); } }); renumber(); syncPresets(); } function initDashboardExperience() { const summaryOverlay = document.querySelector("[data-summary-overlay]"); const openSummary = document.querySelector("[data-summary-overlay-open]"); const closeSummary = [...document.querySelectorAll("[data-summary-overlay-close]")]; const momentOverlay = document.querySelector("[data-moment-overlay]"); const settingsMenuOverlay = document.querySelector("[data-settings-menu-overlay]"); const openSettingsMenu = document.querySelector("[data-settings-menu-open]"); const closeSettingsMenu = [...document.querySelectorAll("[data-settings-menu-close]")]; const openMoment = document.querySelector("[data-moment-overlay-open]"); const fabMenu = document.querySelector("[data-fab-menu]"); const closeMoment = [...document.querySelectorAll("[data-moment-overlay-close]")]; const chooseStep = document.querySelector('[data-moment-step="choose"]'); const formStep = document.querySelector('[data-moment-step="form"]'); const momentSubmit = document.querySelector("[data-moment-submit]"); const typeInput = document.querySelector("[data-moment-type-input]"); const formNameInput = document.querySelector("[data-moment-form-name]"); const eventIdInput = document.querySelector("[data-moment-event-id]"); const typeLabel = document.querySelector("[data-moment-type-label]"); const valueField = document.querySelector("[data-moment-value-field]"); const valueLabel = document.querySelector("[data-moment-value-label]"); const valueInput = document.querySelector("[data-moment-value-input]"); const walkField = document.querySelector("[data-moment-walk-field]"); const walkModeInput = document.querySelector("[data-walk-mode-input]"); const sportField = document.querySelector("[data-moment-sport-field]"); const alcoholField = document.querySelector("[data-moment-alcohol-field]"); const momentComment = document.querySelector("[data-moment-comment]"); const backButton = document.querySelector("[data-moment-back]"); const deleteForm = document.querySelector("[data-moment-delete-form]"); const deleteIdInput = document.querySelector("[data-moment-delete-id]"); const typeSelect = document.querySelector("[data-event-type-select]"); const unitInput = document.querySelector("[data-event-unit]"); const swipeContainer = document.querySelector("[data-day-swipe]"); const dayStrip = document.querySelector("[data-day-strip]"); const daySlider = document.querySelector("[data-day-slider]"); const periodRail = document.querySelector(".range-period-rail"); const walkMode = document.body.dataset.walkMode || "time"; const walkUnit = walkMode === "steps" ? "steps" : "min"; const hoistOverlay = overlay => { if (!(overlay instanceof HTMLElement)) { return; } if (overlay.parentElement !== document.body) { document.body.appendChild(overlay); } }; hoistOverlay(summaryOverlay); hoistOverlay(momentOverlay); hoistOverlay(settingsMenuOverlay); if (periodRail instanceof HTMLElement) { const params = new URLSearchParams(window.location.search); const view = params.get("view") || "day"; const storageKey = `mood:${view}:period-scroll`; const storedScroll = window.sessionStorage.getItem(storageKey); if (storedScroll !== null) { periodRail.scrollLeft = Number(storedScroll) || 0; } periodRail.addEventListener("click", event => { const link = event.target.closest("a[href]"); if (!link) { return; } window.sessionStorage.setItem(storageKey, String(periodRail.scrollLeft)); }); } const stepperConfigs = { event: { label: "Moment", valueLabel: "Wert", unit: "", placeholder: "optional", showValue: false, showSport: false, showAlcohol: false, commentPlaceholder: "Was hast du erlebt?" }, walk: { label: "Spaziergang", valueLabel: "Spaziergang", unit: walkUnit, placeholder: walkMode === "steps" ? "Schritte" : "Minuten", showValue: true, showSport: false, showAlcohol: false, showWalk: true, commentPlaceholder: "Was war dabei besonders?" }, sport: { label: "Sport", valueLabel: "Dauer", unit: "min", placeholder: "Minuten", showValue: true, showSport: true, showAlcohol: false, commentPlaceholder: "Was hast du gemacht?" }, sleep: { label: "Schlaf", valueLabel: "Dauer", unit: "h", placeholder: "Stunden", showValue: true, showSport: false, showAlcohol: false, commentPlaceholder: "Wie war der Schlaf?" }, alcohol: { label: "Alkohol", valueLabel: "", unit: "", placeholder: "", showValue: false, showSport: false, showAlcohol: true, commentPlaceholder: "Optionaler Kommentar" }, }; const toneClass = (value, metric = "mood") => { const current = Math.max(-2, Math.min(2, Number(value || 0))); if (metric === "stress") { if (current <= -2) return "tone-pos2"; if (current === -1) return "tone-pos1"; if (current === 1) return "tone-neg1"; if (current >= 2) return "tone-neg2"; return "tone-zero"; } if (current <= -2) return "tone-neg2"; if (current === -1) return "tone-neg1"; if (current === 1) return "tone-pos1"; if (current >= 2) return "tone-pos2"; return "tone-zero"; }; const signalLabels = { mood: { "-2": "sehr niedrig", "-1": "niedrig", 0: "neutral", 1: "hoch", 2: "sehr hoch" }, energy: { "-2": "leer", "-1": "matt", 0: "okay", 1: "wach", 2: "kraftvoll" }, stress: { "-2": "sehr ruhig", "-1": "ruhig", 0: "neutral", 1: "angespannt", 2: "sehr angespannt" }, }; const setHidden = (element, hidden) => { if (!element) { return; } if (hidden) { element.setAttribute("hidden", "hidden"); } else { element.removeAttribute("hidden"); } }; const setOverlay = (overlay, open) => { if (!overlay) { return; } setHidden(overlay, !open); document.body.classList.toggle("is-dashboard-overlay-open", open); if (open) { const focusTarget = overlay.querySelector("button, [href]"); if (focusTarget instanceof HTMLElement) { window.setTimeout(() => focusTarget.focus(), 10); } } }; document.querySelectorAll("[data-stepper]").forEach(stepper => { const input = stepper.querySelector("[data-stepper-input]"); const value = stepper.querySelector("[data-stepper-value]"); const minus = stepper.querySelector("[data-stepper-minus]"); const plus = stepper.querySelector("[data-stepper-plus]"); if (!input || !value || !minus || !plus) { return; } const render = () => { const current = Math.max(-2, Math.min(2, Number(input.value || 0))); const metric = stepper.dataset.stepperMetric || "mood"; input.value = String(current); value.textContent = `${current > 0 ? "+" : ""}${current}`; const label = stepper.querySelector("[data-stepper-label]"); if (label) { label.textContent = signalLabels[metric]?.[current] || signalLabels.mood[current] || "neutral"; } minus.disabled = current <= -2; plus.disabled = current >= 2; const ring = stepper.querySelector(".overlay-signal-card__ring"); if (ring) { ring.classList.remove("tone-neg2", "tone-neg1", "tone-zero", "tone-pos1", "tone-pos2"); ring.classList.add(toneClass(current, metric)); } }; minus.addEventListener("click", () => { input.value = String(Number(input.value || 0) - 1); render(); }); plus.addEventListener("click", () => { input.value = String(Number(input.value || 0) + 1); render(); }); render(); }); if (openSummary) { openSummary.addEventListener("click", event => { event.preventDefault(); setOverlay(summaryOverlay, true); }); } closeSummary.forEach(button => { button.addEventListener("click", event => { event.preventDefault(); setOverlay(summaryOverlay, false); }); }); if (openSettingsMenu) { openSettingsMenu.addEventListener("click", event => { event.preventDefault(); setOverlay(settingsMenuOverlay, true); }); } closeSettingsMenu.forEach(button => { button.addEventListener("click", event => { event.preventDefault(); setOverlay(settingsMenuOverlay, false); }); }); const setMomentEditMode = payload => { if (!formNameInput || !eventIdInput) { return; } if (payload) { formNameInput.value = "update_event"; eventIdInput.value = payload.id || ""; if (deleteForm && deleteIdInput) { deleteIdInput.value = payload.id || ""; setHidden(deleteForm, false); } return; } formNameInput.value = "add_event"; eventIdInput.value = ""; if (deleteForm && deleteIdInput) { deleteIdInput.value = ""; setHidden(deleteForm, true); } }; const showMomentChoose = () => { setMomentEditMode(null); setHidden(chooseStep, false); setHidden(formStep, true); document.querySelectorAll("[data-sport-choice]").forEach(item => item.classList.remove("is-selected")); if (momentSubmit) { momentSubmit.disabled = true; } }; const showMomentForm = type => { const config = stepperConfigs[type] || stepperConfigs.event; if (typeInput) typeInput.value = type; if (typeLabel) typeLabel.textContent = config.label; if (valueLabel) valueLabel.textContent = config.valueLabel || "Wert"; if (valueInput) { valueInput.placeholder = config.placeholder; valueInput.required = !!config.showValue; valueInput.value = config.showValue ? valueInput.value : ""; valueInput.step = type === "sleep" ? "0.01" : "1"; } if (unitInput) { unitInput.value = config.unit; } if (walkModeInput) { walkModeInput.value = walkMode; } if (momentComment) { momentComment.placeholder = config.commentPlaceholder; momentComment.required = type !== "alcohol"; } if (sportField) setHidden(sportField, !config.showSport); if (alcoholField) setHidden(alcoholField, !config.showAlcohol); if (valueField) setHidden(valueField, !config.showValue); if (walkField) setHidden(walkField, !config.showWalk); if (momentSubmit) { momentSubmit.disabled = false; } if (config.showWalk) { document.querySelectorAll('input[name="event_walk_mode"]').forEach(radio => { radio.checked = radio.value === walkMode; }); } setHidden(chooseStep, true); setHidden(formStep, false); }; const populateMomentForm = payload => { showMomentForm(payload.type || "event"); setMomentEditMode(payload); const form = document.querySelector("#moment-form"); if (!form) { return; } const setValue = (selector, value) => { const field = form.querySelector(selector); if (field) { field.value = value; } }; setValue('input[name="event_time"]', payload.time || ""); setValue('[name="event_comment"]', payload.comment || ""); setValue('[name="event_value"]', payload.value || ""); setValue('[name="event_sport_type_id"]', payload.sport_type_id || ""); setValue('[name="event_unit"]', payload.unit || ""); setValue('[name="event_walk_mode"]', payload.unit === "steps" ? "steps" : "time"); setValue('[name="event_mood"]', payload.mood ?? 0); setValue('[name="event_energy"]', payload.energy ?? 0); setValue('[name="event_stress"]', payload.stress ?? 0); const consumedYes = form.querySelector('input[name="event_consumed"][value="1"]'); const consumedNo = form.querySelector('input[name="event_consumed"][value="0"]'); if (consumedYes && consumedNo) { if (payload.consumed) { consumedYes.checked = true; } else { consumedNo.checked = true; } } form.querySelectorAll('input[name="event_walk_mode"]').forEach(radio => { radio.checked = radio.value === (payload.unit === "steps" ? "steps" : "time"); }); form.querySelectorAll("[data-stepper]").forEach(stepper => { const input = stepper.querySelector("[data-stepper-input]"); const value = stepper.querySelector("[data-stepper-value]"); const minus = stepper.querySelector("[data-stepper-minus]"); const plus = stepper.querySelector("[data-stepper-plus]"); if (!input || !value || !minus || !plus) return; const current = Math.max(-2, Math.min(2, Number(input.value || 0))); const metric = stepper.dataset.stepperMetric || "mood"; input.value = String(current); value.textContent = `${current > 0 ? "+" : ""}${current}`; minus.disabled = current <= -2; plus.disabled = current >= 2; const ring = stepper.querySelector(".overlay-signal-card__ring"); if (ring) { ring.classList.remove("tone-neg2", "tone-neg1", "tone-zero", "tone-pos1", "tone-pos2"); ring.classList.add(toneClass(current, metric)); } }); form.querySelectorAll("[data-sport-choice]").forEach(button => { button.classList.toggle("is-selected", button.dataset.sportChoice === (payload.sport_type_id || "")); }); }; if (openMoment) { openMoment.addEventListener("click", event => { event.preventDefault(); if (fabMenu instanceof HTMLElement) { fabMenu.hidden = !fabMenu.hidden; openMoment.classList.toggle("is-open", !fabMenu.hidden); return; } showMomentChoose(); setOverlay(momentOverlay, true); }); } document.querySelectorAll("[data-fab-moment-choice]").forEach(button => { button.addEventListener("click", event => { event.preventDefault(); const type = button.dataset.fabMomentChoice || "event"; if (fabMenu instanceof HTMLElement) { fabMenu.hidden = true; } if (openMoment) { openMoment.classList.remove("is-open"); } showMomentForm(type); setOverlay(momentOverlay, true); }); }); document.addEventListener("click", event => { if (!(fabMenu instanceof HTMLElement) || fabMenu.hidden) { return; } if (event.target.closest("[data-fab-menu]") || event.target.closest("[data-moment-overlay-open]")) { return; } fabMenu.hidden = true; if (openMoment) { openMoment.classList.remove("is-open"); } }); closeMoment.forEach(button => { button.addEventListener("click", event => { event.preventDefault(); setOverlay(momentOverlay, false); showMomentChoose(); }); }); document.querySelectorAll("[data-moment-type-choice]").forEach(button => { button.addEventListener("click", event => { event.preventDefault(); showMomentForm(button.dataset.momentTypeChoice || "event"); }); }); if (backButton) { backButton.addEventListener("click", event => { event.preventDefault(); showMomentChoose(); }); } document.querySelectorAll("[data-sport-choice]").forEach(button => { button.addEventListener("click", event => { event.preventDefault(); const hidden = document.querySelector('input[name="event_sport_type_id"]'); if (!hidden) { return; } hidden.value = button.dataset.sportChoice || ""; document.querySelectorAll("[data-sport-choice]").forEach(item => item.classList.remove("is-selected")); button.classList.add("is-selected"); }); }); document.querySelectorAll('input[name="event_walk_mode"]').forEach(radio => { radio.addEventListener("change", () => { if (!valueInput || !valueLabel || !unitInput || !walkModeInput) { return; } const mode = radio.checked ? radio.value : walkModeInput.value; walkModeInput.value = mode; unitInput.value = mode === "steps" ? "steps" : "min"; valueLabel.textContent = mode === "steps" ? "Schritte" : "Dauer"; valueInput.placeholder = mode === "steps" ? "Schritte" : "Minuten"; valueInput.step = "1"; }); }); document.querySelectorAll("[data-confirm-delete]").forEach(button => { button.addEventListener("click", event => { if (!window.confirm("Diesen Moment wirklich löschen?")) { event.preventDefault(); event.stopPropagation(); } }); }); document.querySelectorAll("[data-event-editable]").forEach(card => { card.addEventListener("click", event => { if (event.target.closest("form") || event.target.closest("button")) { return; } const payload = decodePayload(card.dataset.eventPayload || ""); if (!payload) { return; } populateMomentForm(payload); setOverlay(momentOverlay, true); }); }); if (typeSelect && unitInput) { const syncUnit = () => { const option = typeSelect.options[typeSelect.selectedIndex]; unitInput.value = option?.dataset.defaultUnit || ""; }; syncUnit(); typeSelect.addEventListener("change", syncUnit); } if (swipeContainer && dayStrip && daySlider) { let pointerStartX = 0; let pointerStartY = 0; let dragging = false; let didSwipe = false; let activePointerId = null; let currentOffset = 0; let targetOffset = 0; let animationFrame = null; const prefetchedDays = new Set(); const setSlideProgress = offset => { const progress = Math.min(1, Math.abs(offset) / 120); daySlider.style.setProperty("--day-slider-offset", `${offset.toFixed(1)}px`); daySlider.style.setProperty("--day-slider-scale", (1 - (progress * 0.025)).toFixed(3)); swipeContainer.style.setProperty("--day-prev-hint", offset > 0 ? progress.toFixed(3) : "0"); swipeContainer.style.setProperty("--day-next-hint", offset < 0 ? progress.toFixed(3) : "0"); }; const animateSlide = () => { currentOffset += (targetOffset - currentOffset) * 0.34; if (Math.abs(targetOffset - currentOffset) < 0.4) { currentOffset = targetOffset; } setSlideProgress(currentOffset); if (currentOffset !== targetOffset) { animationFrame = window.requestAnimationFrame(animateSlide); } else { animationFrame = null; } }; const setTargetOffset = offset => { targetOffset = offset; if (animationFrame === null) { animationFrame = window.requestAnimationFrame(animateSlide); } }; const preloadDay = date => { if (!date || prefetchedDays.has(date)) { return; } prefetchedDays.add(date); window.fetch(dashboardDayPath(date), { credentials: "same-origin", cache: "force-cache", priority: "low" }).catch(() => {}); }; preloadDay(swipeContainer.dataset.prevDate); preloadDay(swipeContainer.dataset.nextDate); const resetStrip = () => { dayStrip.classList.remove("is-dragging"); daySlider.classList.remove("is-dragging"); setTargetOffset(0); }; const handleSwipe = (deltaX, deltaY) => { if (document.body.classList.contains("is-dashboard-overlay-open") || Math.abs(deltaX) < 70 || Math.abs(deltaY) > 50) { resetStrip(); return; } if (deltaX < 0 && swipeContainer.dataset.nextDate) { didSwipe = true; setSlideProgress(-window.innerWidth); window.location.href = dashboardDayPath(swipeContainer.dataset.nextDate); } else if (deltaX > 0 && swipeContainer.dataset.prevDate) { didSwipe = true; setSlideProgress(window.innerWidth); window.location.href = dashboardDayPath(swipeContainer.dataset.prevDate); } else { resetStrip(); } }; daySlider.addEventListener("pointerdown", event => { if (event.target.closest("input, textarea, select, label, [data-stepper], .dashboard-overlay")) { dragging = false; return; } didSwipe = false; dragging = true; activePointerId = event.pointerId; pointerStartX = event.clientX; pointerStartY = event.clientY; dayStrip.classList.add("is-dragging"); daySlider.classList.add("is-dragging"); daySlider.setPointerCapture?.(event.pointerId); }); daySlider.addEventListener("pointermove", event => { if (!dragging || (activePointerId !== null && event.pointerId !== activePointerId)) { return; } const deltaX = event.clientX - pointerStartX; const deltaY = event.clientY - pointerStartY; if (Math.abs(deltaY) > Math.abs(deltaX) && Math.abs(deltaY) > 20) { dragging = false; activePointerId = null; resetStrip(); return; } const dampedOffset = Math.sign(deltaX) * Math.min(148, Math.pow(Math.abs(deltaX), 0.88) * 1.6); setTargetOffset(dampedOffset); if (deltaX < -32) { preloadDay(swipeContainer.dataset.nextDate); } else if (deltaX > 32) { preloadDay(swipeContainer.dataset.prevDate); } }); daySlider.addEventListener("pointerup", event => { if (!dragging) { return; } dragging = false; activePointerId = null; handleSwipe(event.clientX - pointerStartX, event.clientY - pointerStartY); }); daySlider.addEventListener("pointercancel", () => { dragging = false; activePointerId = null; resetStrip(); }); daySlider.addEventListener("lostpointercapture", () => { if (!dragging) { return; } dragging = false; activePointerId = null; resetStrip(); }); daySlider.addEventListener("click", event => { if (!didSwipe) { return; } event.preventDefault(); event.stopPropagation(); didSwipe = false; }, true); window.addEventListener("keydown", event => { if (document.body.classList.contains("is-dashboard-overlay-open")) { return; } if (event.key === "ArrowLeft" && swipeContainer.dataset.nextDate) { window.location.href = dashboardDayPath(swipeContainer.dataset.nextDate); } if (event.key === "ArrowRight" && swipeContainer.dataset.prevDate) { window.location.href = dashboardDayPath(swipeContainer.dataset.prevDate); } }); } } function initOptionsPanels() { const overlay = document.querySelector("[data-options-overlay]"); if (!overlay) { return; } if (overlay.parentElement !== document.body) { document.body.appendChild(overlay); } const panels = [...overlay.querySelectorAll("[data-options-panel]")]; const menu = overlay.querySelector("[data-options-menu]"); const closeButtons = [...overlay.querySelectorAll("[data-options-close]")]; const backButtons = [...overlay.querySelectorAll("[data-options-back]")]; const isStandalone = overlay.dataset.optionsStandalone === "1"; const initialPanel = overlay.dataset.openPanel || null; const setOpen = (panelName) => { overlay.hidden = !isStandalone && panelName === null; document.body.classList.toggle("is-dashboard-overlay-open", isStandalone || panelName !== null); if (menu instanceof HTMLElement) { menu.hidden = panelName !== null; } backButtons.forEach(button => { button.hidden = panelName === null; }); panels.forEach(panel => { panel.hidden = panel.dataset.optionsPanel !== panelName; }); if (panelName === "stats") { window.setTimeout(() => { initDashboardCharts(); }, 40); } }; document.querySelectorAll("[data-options-open]").forEach(button => { button.addEventListener("click", event => { event.preventDefault(); setOpen(button.dataset.optionsOpen || null); }); }); closeButtons.forEach(button => { button.addEventListener("click", event => { event.preventDefault(); if (isStandalone) { window.location.href = "/"; return; } setOpen(null); }); }); backButtons.forEach(button => { button.addEventListener("click", event => { event.preventDefault(); setOpen(null); if (window.location.search.includes("panel=")) { window.history.replaceState(null, "", "/options"); } }); }); if (initialPanel) { setOpen(initialPanel); } else if (isStandalone) { setOpen(null); } } function initHealthImportStatus() { const panel = document.querySelector("[data-health-import-status]"); if (!panel) { return; } const progressWrap = panel.querySelector("[data-health-progress-wrap]"); const progress = panel.querySelector("[data-health-progress-bar]"); const progressText = panel.querySelector("[data-health-progress-text]"); const lastImport = panel.querySelector("[data-health-last-import]"); const lastMessage = panel.querySelector("[data-health-last-message]"); let timer = null; const formatDuration = seconds => { const rounded = Math.max(0, Math.round(Number(seconds) || 0)); if (rounded < 60) { return `${rounded} s`; } return `${Math.ceil(rounded / 60)} min`; }; const render = status => { const done = Math.max(0, Number(status.progress_done || 0)); const total = Math.max(0, Number(status.progress_total || 0)); const percent = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0; if (progress) { progress.value = String(percent); progress.textContent = `${percent}%`; } if (progressWrap) { progressWrap.dataset.progressDone = String(done); progressWrap.dataset.progressTotal = String(total); } if (lastImport) { lastImport.textContent = status.last_import_at ? new Date(status.last_import_at).toLocaleString("de-DE") : "-"; } if (lastMessage) { lastMessage.textContent = status.last_message || "-"; } if (progressText) { if (status.last_status === "running") { let eta = "wird berechnet"; const started = Date.parse(status.started_at || ""); if (started && done > 0 && total > done) { const elapsed = (Date.now() - started) / 1000; eta = formatDuration((elapsed / done) * (total - done)); } progressText.textContent = `Import läuft: ${done} von ${total} verarbeitet. Restzeit ca. ${eta}.`; return; } if (status.last_status === "error") { progressText.textContent = `${status.last_message || "Import fehlgeschlagen."} Derselbe Export kann erneut gesendet werden und wird idempotent übernommen.`; return; } progressText.textContent = status.last_message || "Noch kein Import gelaufen."; } }; const initialDone = progressWrap ? Number(progressWrap.dataset.progressDone || 0) : 0; const initialTotal = progressWrap ? Number(progressWrap.dataset.progressTotal || 0) : 0; render({ progress_done: initialDone, progress_total: initialTotal }); const refresh = async () => { try { const response = await fetch("/api/health/status", { credentials: "same-origin" }); if (!response.ok) { return; } const data = await response.json(); if (!data || !data.ok || !data.status) { return; } render(data.status); if (data.status.last_status !== "running" && timer !== null) { window.clearInterval(timer); timer = null; } } catch (error) { // Status polling is best-effort; the import itself is server-side. } }; refresh(); timer = window.setInterval(refresh, 3500); } function initMediaLightbox() { const lightbox = document.querySelector("[data-media-lightbox]"); const content = document.querySelector("[data-media-lightbox-content]"); if (!(lightbox instanceof HTMLElement) || !(content instanceof HTMLElement)) { return; } if (lightbox.parentElement !== document.body) { document.body.appendChild(lightbox); } const close = () => { lightbox.setAttribute("hidden", "hidden"); content.replaceChildren(); document.body.classList.remove("is-dashboard-overlay-open"); }; document.querySelectorAll("[data-media-lightbox-close]").forEach(button => { button.addEventListener("click", close); }); document.addEventListener("keydown", event => { if (event.key === "Escape" && !lightbox.hasAttribute("hidden")) { close(); } }); document.querySelectorAll("[data-lightbox-kind]").forEach(trigger => { trigger.addEventListener("click", event => { event.preventDefault(); event.stopPropagation(); content.replaceChildren(); if (trigger.dataset.lightboxKind === "image") { const image = document.createElement("img"); image.src = trigger.dataset.lightboxSrc || ""; image.alt = ""; content.appendChild(image); } else { const clone = trigger.cloneNode(true); clone.removeAttribute("data-lightbox-kind"); clone.removeAttribute("aria-label"); clone.classList.add("is-lightbox-clone"); content.appendChild(clone); } lightbox.removeAttribute("hidden"); document.body.classList.add("is-dashboard-overlay-open"); }); }); } function initSleepPhaseTooltips() { document.querySelectorAll(".sleep-phase-bar__segment[data-tooltip]").forEach(segment => { segment.addEventListener("click", event => { event.preventDefault(); event.stopPropagation(); document.querySelectorAll(".sleep-phase-bar__segment.is-tooltip-visible").forEach(active => { if (active !== segment) { active.classList.remove("is-tooltip-visible"); } }); segment.classList.toggle("is-tooltip-visible"); }); }); document.addEventListener("click", event => { if (event.target.closest(".sleep-phase-bar__segment[data-tooltip]")) { return; } document.querySelectorAll(".sleep-phase-bar__segment.is-tooltip-visible").forEach(segment => { segment.classList.remove("is-tooltip-visible"); }); }); } function csrfToken() { return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; } function pushPublicKey() { return document.querySelector('meta[name="mood-push-public-key"]')?.getAttribute("content") || ""; } function base64UrlToUint8Array(value) { const padded = value + "=".repeat((4 - (value.length % 4)) % 4); const base64 = padded.replace(/-/g, "+").replace(/_/g, "/"); const raw = atob(base64); return Uint8Array.from(raw, char => char.charCodeAt(0)); } async function postJson(url, payload) { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken(), }, body: JSON.stringify(payload || {}), }); let data = {}; try { data = await response.json(); } catch (error) { data = {}; } if (!response.ok) { throw new Error(data.message || "Die Anfrage konnte nicht verarbeitet werden."); } return data; } async function initPwaShell() { if (document.body.dataset.authenticated !== "1" || !("serviceWorker" in navigator)) { return; } try { await navigator.serviceWorker.register("/service-worker.js"); } catch (error) { console.warn("Service Worker registration failed", error); } } function isStandaloneMode() { return window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true; } function isAppleTouchDevice() { return /iPhone|iPad|iPod/i.test(window.navigator.userAgent) || (window.navigator.platform === "MacIntel" && window.navigator.maxTouchPoints > 1); } function initPullToRefresh() { if (!isStandaloneMode() || !isAppleTouchDevice()) { return; } const indicator = document.querySelector("[data-pull-refresh-indicator]"); const body = document.body; const threshold = 96; let isTracking = false; let isReady = false; let startY = 0; const setIndicator = message => { if (indicator) { indicator.textContent = message; } }; const resetState = () => { body.classList.remove("is-pull-refreshing", "is-pull-refresh-ready"); isTracking = false; isReady = false; startY = 0; setIndicator("Zum Aktualisieren ziehen"); }; const scrollTop = () => Math.max( window.scrollY || 0, document.documentElement.scrollTop || 0, document.body.scrollTop || 0 ); const canStart = target => { if (scrollTop() > 0) { return false; } if (!(target instanceof Element)) { return true; } return !target.closest("input, textarea, select, button"); }; window.addEventListener("touchstart", event => { if (event.touches.length !== 1 || !canStart(event.target)) { resetState(); return; } isTracking = true; startY = event.touches[0].clientY; }, { passive: true }); window.addEventListener("touchmove", event => { if (!isTracking || event.touches.length !== 1) { return; } if (scrollTop() > 0) { resetState(); return; } const delta = event.touches[0].clientY - startY; if (delta <= 0) { body.classList.remove("is-pull-refreshing", "is-pull-refresh-ready"); isReady = false; setIndicator("Zum Aktualisieren ziehen"); return; } if (delta > 18) { body.classList.add("is-pull-refreshing"); event.preventDefault(); } if (delta >= threshold) { if (!isReady) { body.classList.add("is-pull-refresh-ready"); setIndicator("Loslassen zum Aktualisieren"); isReady = true; } return; } if (isReady) { body.classList.remove("is-pull-refresh-ready"); isReady = false; } setIndicator("Zum Aktualisieren ziehen"); }, { passive: false }); window.addEventListener("touchend", () => { if (!isTracking) { return; } if (isReady) { body.classList.remove("is-pull-refreshing", "is-pull-refresh-ready"); body.classList.add("is-pull-refresh-reloading"); setIndicator("Wird aktualisiert ..."); window.location.reload(); return; } resetState(); }, { passive: true }); window.addEventListener("touchcancel", resetState, { passive: true }); } function initPushControls() { const panel = document.querySelector("[data-push-panel]"); if (!panel) { return; } const statusNode = panel.querySelector("[data-push-status]"); const enableButton = panel.querySelector("[data-push-enable]"); const disableButton = panel.querySelector("[data-push-disable]"); const testButton = panel.querySelector("[data-push-test]"); const ready = panel.dataset.pushReady === "1"; const vapidKey = pushPublicKey(); const setStatus = (message, tone = "neutral") => { if (!statusNode) { return; } statusNode.textContent = message; statusNode.dataset.tone = tone; }; if (!ready || !vapidKey) { setStatus("Push ist auf diesem Server gerade noch nicht bereit.", "error"); return; } if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window)) { setStatus("Dieses Gerät unterstützt Web Push in diesem Browser leider nicht.", "error"); if (enableButton) enableButton.disabled = true; if (disableButton) disableButton.disabled = true; if (testButton) testButton.disabled = true; return; } const updateUi = subscription => { if (subscription) { setStatus("Push ist auf diesem Gerät aktiv. Test und tägliche Erinnerungen können gesendet werden.", "success"); if (enableButton) enableButton.disabled = true; if (disableButton) disableButton.disabled = false; if (testButton) testButton.disabled = false; return; } if (!isStandaloneMode() && /iPhone|iPad|iPod/i.test(navigator.userAgent)) { setStatus("Für iPhone zuerst in Safari öffnen, zum Home-Bildschirm hinzufügen und danach Push aktivieren.", "neutral"); } else { setStatus("Push ist auf diesem Gerät noch nicht aktiv. Du kannst ihn hier direkt einschalten.", "neutral"); } if (enableButton) enableButton.disabled = false; if (disableButton) disableButton.disabled = true; if (testButton) testButton.disabled = true; }; const getRegistration = async () => { await initPwaShell(); return navigator.serviceWorker.ready; }; const getSubscription = async () => { const registration = await getRegistration(); return registration.pushManager.getSubscription(); }; const refreshStatus = async () => { try { updateUi(await getSubscription()); } catch (error) { setStatus("Der Push-Status konnte gerade nicht gelesen werden.", "error"); } }; if (enableButton) { enableButton.addEventListener("click", async () => { try { const permission = await Notification.requestPermission(); if (permission !== "granted") { setStatus("Die Push-Berechtigung wurde nicht erteilt.", "error"); return; } const registration = await getRegistration(); let subscription = await registration.pushManager.getSubscription(); if (!subscription) { subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: base64UrlToUint8Array(vapidKey), }); } await postJson("/push/subscribe", { subscription: subscription.toJSON(), contentEncoding: subscription.options?.applicationServerKey ? "aes128gcm" : "aes128gcm", }); updateUi(subscription); } catch (error) { setStatus(error.message || "Push konnte nicht aktiviert werden.", "error"); } }); } if (disableButton) { disableButton.addEventListener("click", async () => { try { const subscription = await getSubscription(); if (!subscription) { updateUi(null); return; } await postJson("/push/unsubscribe", { endpoint: subscription.endpoint, }); await subscription.unsubscribe(); updateUi(null); } catch (error) { setStatus(error.message || "Push konnte nicht entfernt werden.", "error"); } }); } if (testButton) { testButton.addEventListener("click", async () => { try { const data = await postJson("/push/test", {}); setStatus(data.message || "Die Test-Benachrichtigung wurde verschickt.", "success"); } catch (error) { setStatus(error.message || "Die Test-Benachrichtigung konnte nicht gesendet werden.", "error"); } }); } refreshStatus(); } window.addEventListener("resize", () => { if (!document.querySelector("#calendar-heatmap")) { return; } window.clearTimeout(dashboardResizeTimer); dashboardResizeTimer = window.setTimeout(() => { initDashboardCharts(); }, 120); }); updateRangeOutputs(); initHeaderDatePicker(); initTrackPreview(); initArchiveMobileDetail(); initDashboardCharts(); initDashboardExperience(); initOptionsPanels(); initHealthImportStatus(); initMediaLightbox(); initSleepPhaseTooltips(); initSportTypeManager(); initPwaShell(); initPullToRefresh(); initPushControls(); })();