856 lines
33 KiB
JavaScript
856 lines
33 KiB
JavaScript
(function () {
|
|
const textDecoder = new TextDecoder();
|
|
let dashboardResizeTimer = null;
|
|
|
|
function decodePayload(raw) {
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
|
|
const bytes = Uint8Array.from(atob(raw), char => char.charCodeAt(0));
|
|
return JSON.parse(textDecoder.decode(bytes));
|
|
}
|
|
|
|
function formatNumber(value) {
|
|
const rounded = Math.round(value * 10) / 10;
|
|
return Number.isInteger(rounded)
|
|
? String(rounded)
|
|
: rounded.toLocaleString("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
|
}
|
|
|
|
function formatDateLabel(value) {
|
|
const [year, month, day] = value.split("-");
|
|
return `${day}.${month}.`;
|
|
}
|
|
|
|
function toLocalIso(date) {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
const day = String(date.getDate()).padStart(2, "0");
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
function getCssVar(name, fallback) {
|
|
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
return value || fallback;
|
|
}
|
|
|
|
function moodIconPath(sentiment) {
|
|
return `/assets/icons/mood-${sentiment}.svg`;
|
|
}
|
|
|
|
function sportIconPath(icon) {
|
|
return `/assets/icons/sport-${icon}.svg`;
|
|
}
|
|
|
|
function updateRangeOutputs() {
|
|
document.querySelectorAll("[data-output-for]").forEach(output => {
|
|
const input = document.querySelector(`[name="${output.dataset.outputFor}"]`);
|
|
if (!input) {
|
|
return;
|
|
}
|
|
|
|
const sync = () => {
|
|
output.textContent = input.value;
|
|
};
|
|
|
|
sync();
|
|
input.addEventListener("input", sync);
|
|
});
|
|
}
|
|
|
|
function initHeaderDatePicker() {
|
|
document.querySelectorAll(".topbar-date-input").forEach(input => {
|
|
input.addEventListener("change", () => {
|
|
const form = input.closest("form");
|
|
if (form) {
|
|
form.submit();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function sleepDurationPoints(hours, points) {
|
|
if (hours < 4) {
|
|
return Number(points.lt4 || 0);
|
|
}
|
|
|
|
if (hours >= 10) {
|
|
return Number(points.h10plus || 0);
|
|
}
|
|
|
|
const anchors = {
|
|
4: Number(points.h4 || 0),
|
|
5: Number(points.h5 || 0),
|
|
6: Number(points.h6 || 0),
|
|
7: Number(points.h7 || 0),
|
|
8: Number(points.h8 || 0),
|
|
9: Number(points.h9 || 0),
|
|
10: Number(points.h10plus || 0),
|
|
};
|
|
|
|
const lower = Math.floor(hours);
|
|
const upper = Math.ceil(hours);
|
|
|
|
if (lower === upper) {
|
|
return anchors[lower] || 0;
|
|
}
|
|
|
|
const fraction = hours - lower;
|
|
const lowerPoints = anchors[lower] || 0;
|
|
const upperPoints = anchors[upper] || 0;
|
|
return Math.round((lowerPoints + ((upperPoints - lowerPoints) * fraction)) * 10) / 10;
|
|
}
|
|
|
|
function bandPoints(value, bands) {
|
|
for (const band of bands || []) {
|
|
if (value >= Number(band.min || 0) && value <= Number(band.max || 0)) {
|
|
return Number(band.points || 0);
|
|
}
|
|
}
|
|
|
|
const last = (bands || []).slice(-1)[0];
|
|
return last ? Number(last.points || 0) : 0;
|
|
}
|
|
|
|
function sortedRatings(ratings) {
|
|
return [...(ratings || [])].sort((a, b) => Number(a.min || 0) - Number(b.min || 0));
|
|
}
|
|
|
|
function findSportType(settings, id) {
|
|
if (!id) {
|
|
return null;
|
|
}
|
|
|
|
return (settings.sport_types || []).find(type => type.id === id) || null;
|
|
}
|
|
|
|
function findSportTypes(settings, ids) {
|
|
const selected = Array.isArray(ids) ? ids : (ids ? [ids] : []);
|
|
return selected.map(id => findSportType(settings, id)).filter(Boolean);
|
|
}
|
|
|
|
function sportBonusPoints(entry, settings, previousEntry) {
|
|
const currentSportTypes = findSportTypes(settings, entry.sport_types || entry.sport_type || []);
|
|
|
|
if (Number(entry.sport_minutes || 0) <= 0 || !currentSportTypes.length) {
|
|
return 0;
|
|
}
|
|
|
|
const previousGroups = new Set();
|
|
if (previousEntry && Number(previousEntry.sport_minutes || 0) > 0) {
|
|
for (const type of findSportTypes(settings, previousEntry.sport_types || previousEntry.sport_type || [])) {
|
|
previousGroups.add(type.recovery_group || type.id);
|
|
}
|
|
}
|
|
|
|
let total = 0;
|
|
const groupBonuses = new Map();
|
|
|
|
for (const type of currentSportTypes) {
|
|
const bonus = Number(type.bonus_points || 0);
|
|
if (bonus <= 0) {
|
|
continue;
|
|
}
|
|
|
|
const group = type.recovery_group || type.id;
|
|
if (type.allow_consecutive) {
|
|
total += bonus;
|
|
continue;
|
|
}
|
|
|
|
if (previousGroups.has(group)) {
|
|
continue;
|
|
}
|
|
|
|
groupBonuses.set(group, Math.max(Number(groupBonuses.get(group) || 0), bonus));
|
|
}
|
|
|
|
for (const bonus of groupBonuses.values()) {
|
|
total += bonus;
|
|
}
|
|
|
|
return total;
|
|
}
|
|
|
|
function labelForScore(score, ratings) {
|
|
for (const rating of ratings) {
|
|
if (score >= Number(rating.min || 0) && score <= Number(rating.max || 0)) {
|
|
return rating.label;
|
|
}
|
|
}
|
|
|
|
if (!ratings.length) {
|
|
return "unbewertet";
|
|
}
|
|
|
|
return score < Number(ratings[0].min || 0) ? ratings[0].label : ratings[ratings.length - 1].label;
|
|
}
|
|
|
|
function capLabel(current, cap, ratings) {
|
|
const order = ratings.map(item => item.label);
|
|
const currentIndex = order.indexOf(current);
|
|
const capIndex = order.indexOf(cap);
|
|
|
|
if (currentIndex === -1 || capIndex === -1) {
|
|
return current;
|
|
}
|
|
|
|
return currentIndex > capIndex ? cap : current;
|
|
}
|
|
|
|
function sentimentForLabel(label, ratings) {
|
|
const order = ratings.map(item => item.label);
|
|
const index = order.indexOf(label);
|
|
|
|
if (index === -1 || order.length <= 1) {
|
|
return "steady";
|
|
}
|
|
|
|
const ratio = index / Math.max(order.length - 1, 1);
|
|
|
|
if (ratio <= 0.1) {
|
|
return "storm";
|
|
}
|
|
|
|
if (ratio <= 0.35) {
|
|
return "heavy";
|
|
}
|
|
|
|
if (ratio <= 0.65) {
|
|
return "steady";
|
|
}
|
|
|
|
if (ratio <= 0.9) {
|
|
return "bright";
|
|
}
|
|
|
|
return "radiant";
|
|
}
|
|
|
|
function evaluateEntry(entry, settings, previousEntry = null) {
|
|
const ratings = sortedRatings(settings.ratings || []);
|
|
const scoring = settings.scoring || {};
|
|
const components = {
|
|
mood: Number(entry.mood) * Number(scoring.mood_multiplier || 0),
|
|
energy: Number(entry.energy) * Number(scoring.energy_multiplier || 0),
|
|
stress: (11 - Number(entry.stress)) * Number(scoring.stress_multiplier || 0),
|
|
sleep_hours: sleepDurationPoints(Number(entry.sleep_hours), scoring.sleep_duration_points || {}),
|
|
sleep_feeling: Number(entry.sleep_feeling) * Number(scoring.sleep_feeling_multiplier || 0),
|
|
sport_minutes: bandPoints(Number(entry.sport_minutes), scoring.sport_bands || []),
|
|
sport_bonus: sportBonusPoints(entry, settings, previousEntry),
|
|
walk_minutes: bandPoints(Number(entry.walk_minutes), scoring.walk_bands || []),
|
|
note: String(entry.note || "").trim() === "" ? 0 : Number(scoring.journal_points || 0),
|
|
};
|
|
|
|
const total = Math.round(Object.values(components).reduce((sum, value) => sum + Number(value), 0) * 10) / 10;
|
|
let label = labelForScore(total, ratings);
|
|
|
|
for (const guardrail of settings.guardrails || []) {
|
|
const moodMatch = Number(entry.mood) <= Number(guardrail.mood_max || 10);
|
|
const energyMatch = guardrail.energy_max === null || guardrail.energy_max === ""
|
|
? true
|
|
: Number(entry.energy) <= Number(guardrail.energy_max);
|
|
|
|
if (moodMatch && energyMatch) {
|
|
label = capLabel(label, guardrail.cap_label, ratings);
|
|
}
|
|
}
|
|
|
|
return { total, label, components, sentiment: sentimentForLabel(label, ratings) };
|
|
}
|
|
|
|
function initTrackPreview() {
|
|
const card = document.querySelector("#live-score-card");
|
|
const form = document.querySelector("#tracker-form");
|
|
|
|
if (!card || !form) {
|
|
return;
|
|
}
|
|
|
|
const payload = decodePayload(card.dataset.payload);
|
|
if (!payload) {
|
|
return;
|
|
}
|
|
|
|
const totalNode = card.querySelector("[data-preview-total]");
|
|
const labelNode = card.querySelector("[data-preview-label]");
|
|
const iconNode = card.querySelector("[data-preview-icon]");
|
|
const componentsNode = card.querySelector("[data-preview-components]");
|
|
const componentLabels = {
|
|
mood: "Stimmung",
|
|
energy: "Energie",
|
|
stress: "Stress",
|
|
sleep_hours: "Schlafdauer",
|
|
sleep_feeling: "Schlafgefühl",
|
|
sport_minutes: "Sport",
|
|
sport_bonus: "Sportbonus",
|
|
walk_minutes: "Spaziergang",
|
|
note: "Notiz",
|
|
};
|
|
|
|
const collect = () => ({
|
|
mood: Number(form.elements.mood.value),
|
|
energy: Number(form.elements.energy.value),
|
|
stress: Number(form.elements.stress.value),
|
|
sleep_hours: Number(form.elements.sleep_hours.value || 0),
|
|
sleep_feeling: Number(form.elements.sleep_feeling.value),
|
|
sport_minutes: Number(form.elements.sport_minutes.value || 0),
|
|
sport_types: [...form.querySelectorAll('input[name="sport_types[]"]:checked')].map(input => input.value),
|
|
walk_minutes: Number(form.elements.walk_minutes.value || 0),
|
|
note: form.elements.note.value || "",
|
|
});
|
|
|
|
const render = () => {
|
|
const result = evaluateEntry(collect(), payload.settings, payload.previousEntry || null);
|
|
totalNode.textContent = formatNumber(result.total);
|
|
labelNode.textContent = result.label;
|
|
iconNode.src = moodIconPath(result.sentiment);
|
|
card.dataset.sentiment = result.sentiment;
|
|
document.body.dataset.trackMood = result.sentiment;
|
|
componentsNode.innerHTML = Object.entries(result.components).map(([key, value]) => {
|
|
return `<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 seriesName = container.dataset.series || "";
|
|
const invertScale = seriesName === "stress";
|
|
const values = items.map(item => Number(item.value));
|
|
const width = 760;
|
|
const height = 196;
|
|
const padding = { top: 10, right: 18, bottom: 28, left: 14 };
|
|
let minValue = Math.min(...values);
|
|
let maxValue = Math.max(...values);
|
|
|
|
if (seriesName === "mood" || seriesName === "stress") {
|
|
minValue = Math.max(1, minValue - 1.5);
|
|
maxValue = Math.min(10, maxValue + 1.5);
|
|
} else {
|
|
minValue = Math.max(0, minValue - 1);
|
|
maxValue = maxValue + 1;
|
|
}
|
|
|
|
if ((maxValue - minValue) < 3) {
|
|
const center = (maxValue + minValue) / 2;
|
|
if (seriesName === "mood" || seriesName === "stress") {
|
|
minValue = Math.max(1, center - 1.5);
|
|
maxValue = Math.min(10, center + 1.5);
|
|
} else {
|
|
minValue = Math.max(0, center - 1.5);
|
|
maxValue = center + 1.5;
|
|
}
|
|
}
|
|
|
|
if (items.length === 1) {
|
|
const only = items[0];
|
|
const chartTop = padding.top;
|
|
const chartBottom = height - padding.bottom;
|
|
const 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 = `
|
|
<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="Verlauf">
|
|
<line class="chart-axis" x1="${width * 0.18}" y1="${chartBottom}" x2="${width * 0.82}" y2="${chartBottom}"></line>
|
|
<line class="chart-guide" x1="${cx}" y1="${cy + 10}" x2="${cx}" y2="${chartBottom}" stroke="${color}"></line>
|
|
<circle class="line-point line-point--solo" cx="${cx}" cy="${cy}" r="7" fill="${color}"></circle>
|
|
<circle class="line-point-glow" cx="${cx}" cy="${cy}" r="18" fill="${color}"></circle>
|
|
<text class="chart-value" x="${cx}" y="${Math.max(18, cy - 18)}" text-anchor="middle">${formatNumber(Number(only.value))}</text>
|
|
<text class="chart-label" x="${cx}" y="${height - 10}" text-anchor="middle">${formatDateLabel(only.date)}</text>
|
|
<title>${formatDateLabel(only.date)}: ${formatNumber(Number(only.value))}</title>
|
|
</svg>
|
|
`;
|
|
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 = `
|
|
<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 = 194;
|
|
const chartHeight = 116;
|
|
const baseY = 150;
|
|
|
|
const bars = recent.map((item, index) => {
|
|
const sport = Number(item.sport || 0);
|
|
const walk = Number(item.walk || 0);
|
|
const total = sport + walk;
|
|
const x = 18 + (index * 34);
|
|
const columnHeight = total > 0 ? Math.max((total / maxValue) * chartHeight, 8) : 0;
|
|
const walkHeight = total > 0 ? (walk / total) * columnHeight : 0;
|
|
const sportHeight = total > 0 ? (sport / total) * columnHeight : 0;
|
|
const backgroundY = baseY - chartHeight;
|
|
const walkY = baseY - walkHeight;
|
|
const sportY = walkY - sportHeight;
|
|
const badgeY = total > 0 ? Math.max(backgroundY + 6, sportY - 24) : (baseY - 20);
|
|
const labels = Array.isArray(item.sport_labels) ? item.sport_labels.filter(Boolean) : [];
|
|
const label = labels.length ? ` · ${labels.join(", ")}` : "";
|
|
const bonus = Number(item.sport_bonus || 0);
|
|
const icons = Array.isArray(item.sport_icons) ? item.sport_icons.slice(0, 3) : [];
|
|
const iconMarkup = icons.length && sport > 0
|
|
? icons.map((icon, iconIndex) => {
|
|
const iconX = x - 7 + (iconIndex * 10);
|
|
const iconY = badgeY - Math.max(0, (icons.length - 1) * 2) + (iconIndex * 2);
|
|
return `
|
|
<circle class="bar-icon-ring" cx="${iconX + 9}" cy="${iconY + 9}" r="8.5"></circle>
|
|
<image class="bar-icon" href="${icon}" x="${iconX + 1.5}" y="${iconY + 1.5}" width="15" height="15" preserveAspectRatio="xMidYMid meet"></image>
|
|
`;
|
|
}).join("") + (bonus > 0 ? `<circle class="bar-bonus-dot" cx="${x + 18}" cy="${badgeY - 2}" r="4"></circle>` : "")
|
|
: "";
|
|
|
|
return `
|
|
<rect class="bar-grid" x="${x}" y="${backgroundY}" width="18" height="${chartHeight}" rx="9"></rect>
|
|
<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${label}${bonus > 0 ? ` · Bonus ${formatNumber(bonus)}` : ""}</title>
|
|
</rect>
|
|
${iconMarkup}
|
|
<text class="bar-value" x="${x + 9}" y="${baseY - chartHeight - 10}">${Math.round(total)}</text>
|
|
<text class="bar-label" x="${x + 9}" y="180" 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;
|
|
}
|
|
|
|
function calendarColor(entry) {
|
|
if (!entry) {
|
|
return "rgba(255, 255, 255, 0.06)";
|
|
}
|
|
|
|
const ratio = Math.max(0, Math.min(1, Number(entry.score) / Math.max(Number(entry.max || 1), 1)));
|
|
|
|
if (ratio <= 0.36) {
|
|
return `rgba(255, 117, 117, ${0.28 + (ratio * 0.8)})`;
|
|
}
|
|
|
|
if (ratio <= 0.52) {
|
|
return `rgba(255, 171, 122, ${0.28 + (ratio * 0.7)})`;
|
|
}
|
|
|
|
if (ratio <= 0.68) {
|
|
return `rgba(132, 193, 208, ${0.24 + (ratio * 0.55)})`;
|
|
}
|
|
|
|
if (ratio <= 0.84) {
|
|
return `rgba(116, 226, 183, ${0.28 + (ratio * 0.6)})`;
|
|
}
|
|
|
|
return `rgba(112, 240, 182, ${0.38 + (ratio * 0.52)})`;
|
|
}
|
|
|
|
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 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))} Punkte · ${item.entry.label}`
|
|
: `${item.date}: kein Eintrag`;
|
|
|
|
if (!item.entry) {
|
|
return `<rect x="${x}" y="${y}" width="${cellSize}" height="${cellSize}" rx="4" fill="${fill}"><title>${title}</title></rect>`;
|
|
}
|
|
|
|
return `
|
|
<a class="calendar-link" data-date="${item.date}" href="/track?date=${encodeURIComponent(item.date)}" aria-label="${title}">
|
|
<rect class="calendar-cell calendar-cell--active ${activeDate === item.date ? "calendar-cell--selected" : ""}" x="${x}" y="${y}" width="${cellSize}" height="${cellSize}" rx="4" fill="${fill}"></rect>
|
|
<title>${title}</title>
|
|
</a>
|
|
`;
|
|
}).join("");
|
|
|
|
const detailMarkup = latestVisibleEntry === null
|
|
? `
|
|
<div class="calendar-detail__meta">
|
|
<span class="calendar-detail__eyebrow">${config.label}</span>
|
|
<strong>Noch kein getrackter Tag</strong>
|
|
<span class="calendar-detail__subtle">Sobald du Einträge speicherst, kannst du sie hier direkt auswählen.</span>
|
|
</div>
|
|
`
|
|
: `
|
|
<div class="calendar-detail__meta">
|
|
<span class="calendar-detail__eyebrow">${config.label}</span>
|
|
<strong data-calendar-date>${formatDateLabel(latestVisibleEntry.date)}</strong>
|
|
<span class="calendar-detail__label" data-calendar-label>${latestVisibleEntry.label}</span>
|
|
</div>
|
|
<div class="calendar-detail__score">
|
|
<span data-calendar-score>${formatNumber(Number(latestVisibleEntry.score))}</span>
|
|
<small>Punkte</small>
|
|
</div>
|
|
<a class="ghost-link calendar-detail__link" data-calendar-link href="/track?date=${encodeURIComponent(latestVisibleEntry.date)}">Tag öffnen</a>
|
|
`;
|
|
|
|
container.innerHTML = `
|
|
<div class="calendar-detail">
|
|
${detailMarkup}
|
|
</div>
|
|
<svg class="calendar-svg" width="${width}" height="${height}" 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>schlecht</span>
|
|
<div class="calendar-scale">
|
|
<span class="calendar-dot calendar-dot--bad"></span>
|
|
<span class="calendar-dot calendar-dot--warn"></span>
|
|
<span class="calendar-dot"></span>
|
|
<span class="calendar-dot calendar-dot--1"></span>
|
|
<span class="calendar-dot calendar-dot--3"></span>
|
|
</div>
|
|
<span>gut</span>
|
|
</div>
|
|
`;
|
|
|
|
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 = `/track?date=${encodeURIComponent(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");
|
|
|
|
if (!list || !addButton || !template) {
|
|
return;
|
|
}
|
|
|
|
const syncPreview = row => {
|
|
const labelInput = row.querySelector('input[data-name-template$="[label]"]');
|
|
const iconSelect = row.querySelector('select[data-name-template$="[icon]"]');
|
|
const previewText = row.querySelector(".sport-pill span:last-child");
|
|
const previewImage = row.querySelector(".sport-pill img");
|
|
|
|
if (previewText) {
|
|
previewText.textContent = (labelInput && labelInput.value.trim()) || "Neue Sportart";
|
|
}
|
|
|
|
if (previewImage && iconSelect) {
|
|
previewImage.src = sportIconPath(iconSelect.value || "run");
|
|
}
|
|
};
|
|
|
|
const renumber = () => {
|
|
[...list.querySelectorAll("[data-sport-type-row]")].forEach((row, index) => {
|
|
row.querySelectorAll("[data-name-template]").forEach(field => {
|
|
field.name = field.dataset.nameTemplate.replace(/__INDEX__/g, String(index));
|
|
});
|
|
syncPreview(row);
|
|
});
|
|
};
|
|
|
|
addButton.addEventListener("click", () => {
|
|
list.append(template.content.cloneNode(true));
|
|
renumber();
|
|
});
|
|
|
|
list.addEventListener("click", event => {
|
|
const button = event.target.closest("[data-remove-sport-type]");
|
|
if (!button) {
|
|
return;
|
|
}
|
|
|
|
const row = button.closest("[data-sport-type-row]");
|
|
if (!row) {
|
|
return;
|
|
}
|
|
|
|
const rows = list.querySelectorAll("[data-sport-type-row]");
|
|
if (rows.length <= 1) {
|
|
row.querySelectorAll("input[type='text'], input[type='hidden']").forEach(input => {
|
|
input.value = "";
|
|
});
|
|
row.querySelectorAll("input[type='number']").forEach(input => {
|
|
input.value = "2";
|
|
});
|
|
row.querySelectorAll("input[type='checkbox']").forEach(input => {
|
|
input.checked = false;
|
|
});
|
|
row.querySelectorAll("select").forEach(select => {
|
|
select.value = "run";
|
|
});
|
|
syncPreview(row);
|
|
return;
|
|
}
|
|
|
|
row.remove();
|
|
renumber();
|
|
});
|
|
|
|
list.addEventListener("input", event => {
|
|
const row = event.target.closest("[data-sport-type-row]");
|
|
if (row) {
|
|
syncPreview(row);
|
|
}
|
|
});
|
|
|
|
list.addEventListener("change", event => {
|
|
const row = event.target.closest("[data-sport-type-row]");
|
|
if (row) {
|
|
syncPreview(row);
|
|
}
|
|
});
|
|
|
|
renumber();
|
|
}
|
|
|
|
window.addEventListener("resize", () => {
|
|
if (!document.querySelector("#calendar-heatmap")) {
|
|
return;
|
|
}
|
|
|
|
window.clearTimeout(dashboardResizeTimer);
|
|
dashboardResizeTimer = window.setTimeout(() => {
|
|
initDashboardCharts();
|
|
}, 120);
|
|
});
|
|
|
|
updateRangeOutputs();
|
|
initHeaderDatePicker();
|
|
initTrackPreview();
|
|
initDashboardCharts();
|
|
initSportTypeManager();
|
|
})();
|