feat(dashboard): add immersive day range views

This commit is contained in:
2026-05-18 16:32:22 +02:00
parent e953d0fd42
commit 83b4686b6f
12 changed files with 3724 additions and 567 deletions
+535 -3
View File
@@ -39,6 +39,10 @@
return `/assets/icons/mood-${sentiment}.svg`;
}
function dashboardDayPath(date) {
return `/?view=day&date=${encodeURIComponent(date)}`;
}
function sportIconPath(icon) {
return `/assets/icons/sport-${icon}.svg`;
}
@@ -705,7 +709,7 @@
}
return `
<a class="calendar-link" data-date="${item.date}" href="/track?date=${encodeURIComponent(item.date)}" aria-label="${title}">
<a class="calendar-link" data-date="${item.date}" href="${dashboardDayPath(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>
@@ -730,7 +734,7 @@
<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>
<a class="ghost-link calendar-detail__link" data-calendar-link href="${dashboardDayPath(latestVisibleEntry.date)}">Tag öffnen</a>
`;
container.innerHTML = `
@@ -773,7 +777,7 @@
detailDate.textContent = formatDateLabel(entry.date);
detailLabel.textContent = entry.label;
detailScore.textContent = formatNumber(Number(entry.score));
detailLink.href = `/track?date=${encodeURIComponent(entry.date)}`;
detailLink.href = dashboardDayPath(entry.date);
container.querySelectorAll(".calendar-cell--selected").forEach(cell => {
cell.classList.remove("calendar-cell--selected");
@@ -971,6 +975,532 @@
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 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 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: "Ereignis", 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 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("input, textarea, select, button");
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}`;
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.25" : "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();
showMomentChoose();
setOverlay(momentOverlay, true);
});
}
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) {
let pointerStartX = 0;
let pointerStartY = 0;
let dragging = false;
const handleSwipe = (deltaX, deltaY) => {
if (Math.abs(deltaX) < 70 || Math.abs(deltaY) > 50) {
return;
}
if (deltaX < 0 && swipeContainer.dataset.nextDate) {
window.location.href = dashboardDayPath(swipeContainer.dataset.nextDate);
} else if (deltaX > 0 && swipeContainer.dataset.prevDate) {
window.location.href = dashboardDayPath(swipeContainer.dataset.prevDate);
}
};
swipeContainer.addEventListener("pointerdown", event => {
dragging = true;
pointerStartX = event.clientX;
pointerStartY = event.clientY;
});
swipeContainer.addEventListener("pointerup", event => {
if (!dragging) {
return;
}
dragging = false;
handleSwipe(event.clientX - pointerStartX, event.clientY - pointerStartY);
});
swipeContainer.addEventListener("pointercancel", () => {
dragging = false;
});
window.addEventListener("keydown", event => {
if (document.body.classList.contains("is-dashboard-overlay-open")) {
return;
}
if (event.key === "ArrowLeft" && swipeContainer.dataset.prevDate) {
window.location.href = dashboardDayPath(swipeContainer.dataset.prevDate);
}
if (event.key === "ArrowRight" && swipeContainer.dataset.nextDate) {
window.location.href = dashboardDayPath(swipeContainer.dataset.nextDate);
}
});
}
}
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 closeButtons = [...overlay.querySelectorAll("[data-options-close]")];
const backButtons = [...overlay.querySelectorAll("[data-options-back]")];
const initialPanel = overlay.dataset.openPanel || null;
const setOpen = (panelName) => {
overlay.hidden = panelName === null;
document.body.classList.toggle("is-dashboard-overlay-open", 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();
setOpen(null);
});
});
backButtons.forEach(button => {
button.addEventListener("click", event => {
event.preventDefault();
setOpen(null);
});
});
if (initialPanel) {
setOpen(initialPanel);
}
}
function csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || "";
}
@@ -1300,6 +1830,8 @@
initTrackPreview();
initArchiveMobileDetail();
initDashboardCharts();
initDashboardExperience();
initOptionsPanels();
initSportTypeManager();
initPwaShell();
initPullToRefresh();