(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 = `
`;
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 = `
`;
}
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 = `
`;
}
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}
`;
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) => {
const absX = Math.abs(deltaX);
const absY = Math.abs(deltaY);
if (document.body.classList.contains("is-dashboard-overlay-open") || absX < 55 || absY > Math.max(120, absX * 1.15)) {
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.max(52, Math.abs(deltaX) * 1.35)) {
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();
})();