Files
mood-tracking/assets/js/app.js
T
2026-04-11 18:57:00 +02:00

425 lines
17 KiB
JavaScript

(function () {
const textDecoder = new TextDecoder();
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 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 sleepDurationPoints(hours, points) {
if (hours < 4) {
return Number(points.lt4 || 0);
}
if (hours >= 10) {
return Number(points.h10plus || 0);
}
const anchors = {
4: Number(points.h4 || 0),
5: Number(points.h5 || 0),
6: Number(points.h6 || 0),
7: Number(points.h7 || 0),
8: Number(points.h8 || 0),
9: Number(points.h9 || 0),
10: Number(points.h10plus || 0),
};
const lower = Math.floor(hours);
const upper = Math.ceil(hours);
if (lower === upper) {
return anchors[lower] || 0;
}
const fraction = hours - lower;
const lowerPoints = anchors[lower] || 0;
const upperPoints = anchors[upper] || 0;
return Math.round((lowerPoints + ((upperPoints - lowerPoints) * fraction)) * 10) / 10;
}
function bandPoints(value, bands) {
for (const band of bands || []) {
if (value >= Number(band.min || 0) && value <= Number(band.max || 0)) {
return Number(band.points || 0);
}
}
const last = (bands || []).slice(-1)[0];
return last ? Number(last.points || 0) : 0;
}
function sortedRatings(ratings) {
return [...(ratings || [])].sort((a, b) => Number(a.min || 0) - Number(b.min || 0));
}
function 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 evaluateEntry(entry, settings) {
const ratings = sortedRatings(settings.ratings || []);
const scoring = settings.scoring || {};
const components = {
mood: Number(entry.mood) * Number(scoring.mood_multiplier || 0),
energy: Number(entry.energy) * Number(scoring.energy_multiplier || 0),
stress: (11 - Number(entry.stress)) * Number(scoring.stress_multiplier || 0),
sleep_hours: sleepDurationPoints(Number(entry.sleep_hours), scoring.sleep_duration_points || {}),
sleep_feeling: Number(entry.sleep_feeling) * Number(scoring.sleep_feeling_multiplier || 0),
sport_minutes: bandPoints(Number(entry.sport_minutes), scoring.sport_bands || []),
walk_minutes: bandPoints(Number(entry.walk_minutes), scoring.walk_bands || []),
note: String(entry.note || "").trim() === "" ? 0 : Number(scoring.journal_points || 0),
};
const total = Math.round(Object.values(components).reduce((sum, value) => sum + Number(value), 0) * 10) / 10;
let label = labelForScore(total, ratings);
for (const guardrail of settings.guardrails || []) {
const moodMatch = Number(entry.mood) <= Number(guardrail.mood_max || 10);
const energyMatch = guardrail.energy_max === null || guardrail.energy_max === ""
? true
: Number(entry.energy) <= Number(guardrail.energy_max);
if (moodMatch && energyMatch) {
label = capLabel(label, guardrail.cap_label, ratings);
}
}
return { total, label, components };
}
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 componentsNode = card.querySelector("[data-preview-components]");
const componentLabels = {
mood: "Stimmung",
energy: "Energie",
stress: "Stress",
sleep_hours: "Schlafdauer",
sleep_feeling: "Schlafgefühl",
sport_minutes: "Sport",
walk_minutes: "Spaziergang",
note: "Notiz",
};
const collect = () => ({
mood: Number(form.elements.mood.value),
energy: Number(form.elements.energy.value),
stress: Number(form.elements.stress.value),
sleep_hours: Number(form.elements.sleep_hours.value || 0),
sleep_feeling: Number(form.elements.sleep_feeling.value),
sport_minutes: Number(form.elements.sport_minutes.value || 0),
walk_minutes: Number(form.elements.walk_minutes.value || 0),
note: form.elements.note.value || "",
});
const render = () => {
const result = evaluateEntry(collect(), payload.settings);
totalNode.textContent = formatNumber(result.total);
labelNode.textContent = result.label;
componentsNode.innerHTML = Object.entries(result.components).map(([key, value]) => {
return `<div><dt>${componentLabels[key] || key}</dt><dd>${formatNumber(Number(value))}</dd></div>`;
}).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 width = 760;
const height = 220;
const padding = { top: 18, right: 18, bottom: 38, left: 14 };
const maxValue = Math.max(...items.map(item => Number(item.value)), 10);
const minValue = 0;
const step = items.length > 1 ? (width - padding.left - padding.right) / (items.length - 1) : 0;
const points = items.map((item, index) => {
const ratio = (Number(item.value) - minValue) / Math.max(maxValue - minValue, 1);
return {
x: padding.left + (index * step),
y: padding.top + ((1 - ratio) * (height - padding.top - padding.bottom)),
label: formatDateLabel(item.date),
value: Number(item.value),
};
});
const path = linePath(points);
const fillPath = `${path} L ${points[points.length - 1].x} ${height - padding.bottom} L ${points[0].x} ${height - padding.bottom} Z`;
const labels = points.filter((_, index) => index === 0 || index === points.length - 1 || index % Math.ceil(points.length / 5) === 0);
container.innerHTML = `
<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="Verlauf">
<line class="chart-axis" x1="${padding.left}" y1="${height - padding.bottom}" x2="${width - padding.right}" y2="${height - padding.bottom}"></line>
<path class="line-fill" d="${fillPath}" fill="${color}"></path>
<path class="line-stroke" d="${path}" stroke="${color}"></path>
${points.map(point => `<circle class="line-point" cx="${point.x}" cy="${point.y}" r="4" fill="${color}"><title>${point.label}: ${formatNumber(point.value)}</title></circle>`).join("")}
${labels.map(point => `<text class="chart-label" x="${point.x}" y="${height - 12}" text-anchor="middle">${point.label}</text>`).join("")}
</svg>
`;
}
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 = 220;
const chartHeight = 150;
const baseY = 176;
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;
return `
<rect class="bar-grid" x="${x}" y="${backgroundY}" width="18" height="${chartHeight}" rx="9"></rect>
<rect class="bar-segment--walk" x="${x}" y="${walkY}" width="18" height="${walkHeight}" rx="9">
<title>${formatDateLabel(item.date)} · Spaziergang ${walk} min</title>
</rect>
<rect class="bar-segment--sport" x="${x}" y="${sportY}" width="18" height="${sportHeight}" rx="9">
<title>${formatDateLabel(item.date)} · Sport ${sport} min</title>
</rect>
<text class="bar-value" x="${x + 9}" y="${baseY - chartHeight - 10}">${Math.round(total)}</text>
<text class="bar-label" x="${x + 9}" y="202" text-anchor="middle">${formatDateLabel(item.date)}</text>
`;
}).join("");
container.innerHTML = `
<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="Sport und Spaziergang">
${bars}
</svg>
`;
}
function renderCalendar(container, items) {
if (!items || !items.length) {
container.append(emptyState("Der Kalender füllt sich automatisch mit den gespeicherten Tagen."));
return;
}
const map = new Map(items.map(item => [item.date, item]));
const end = new Date();
const start = new Date(end);
start.setDate(end.getDate() - 364);
const days = [];
const cursor = new Date(start);
while (cursor <= end) {
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 width = Math.ceil(days.length / 7) * 17 + 56;
const height = 148;
const cellSize = 12;
const cellGap = 5;
const xOffset = 34;
const yOffset = 24;
const monthLabels = [];
let lastMonth = -1;
const cells = days.map((item, index) => {
const week = Math.floor(index / 7);
const row = item.weekday === 0 ? 6 : item.weekday - 1;
const x = xOffset + (week * (cellSize + cellGap));
const y = yOffset + (row * (cellSize + cellGap));
const level = item.entry ? Math.max(0, Math.min(1, Number(item.entry.score) / Math.max(Number(item.entry.max || 1), 1))) : 0;
const fill = item.entry
? `rgba(126, 239, 205, ${0.18 + (level * 0.72)})`
: "rgba(255, 255, 255, 0.06)";
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))} Punkte · ${item.entry.label}`
: `${item.date}: kein Eintrag`;
return `<rect x="${x}" y="${y}" width="${cellSize}" height="${cellSize}" rx="4" fill="${fill}"><title>${title}</title></rect>`;
}).join("");
container.innerHTML = `
<svg class="calendar-svg" viewBox="0 0 ${width} ${height}" role="img" aria-label="Kalender">
${monthLabels.map(item => `<text class="calendar-tooltip" x="${item.x}" y="14">${item.label}</text>`).join("")}
<text class="calendar-tooltip" x="0" y="34">Mo</text>
<text class="calendar-tooltip" x="0" y="68">Mi</text>
<text class="calendar-tooltip" x="0" y="102">Fr</text>
${cells}
</svg>
<div class="calendar-legend">
<span>weniger</span>
<div class="calendar-scale">
<span class="calendar-dot"></span>
<span class="calendar-dot calendar-dot--1"></span>
<span class="calendar-dot calendar-dot--2"></span>
<span class="calendar-dot calendar-dot--3"></span>
</div>
<span>mehr</span>
</div>
`;
}
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 || [] : []);
});
}
updateRangeOutputs();
initTrackPreview();
initDashboardCharts();
})();