const urlBase64ToUint8Array = (base64String) => { const padding = "=".repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); const rawData = atob(base64); return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))); }; async function registerServiceWorker() { if (!("serviceWorker" in navigator)) return null; return navigator.serviceWorker.register("/static/service-worker.js"); } async function enablePushNotifications() { const vapidPublicKey = document.body.dataset.vapidPublicKey; const csrfToken = document.body.dataset.csrfToken; if (!vapidPublicKey || !("PushManager" in window)) return; const registration = await registerServiceWorker(); const permission = await Notification.requestPermission(); if (permission !== "granted") return; const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), }); await fetch("/planning/push/subscribe", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken, }, body: JSON.stringify(subscription), }); } function injectCsrfTokens() { const csrfToken = document.body.dataset.csrfToken; if (!csrfToken) return; document.querySelectorAll('form[method="post"]').forEach((form) => { let input = form.querySelector('input[name="csrf_token"]'); if (!input) { input = document.createElement("input"); input.type = "hidden"; input.name = "csrf_token"; form.appendChild(input); } input.value = csrfToken; }); } function mountCharts() { const palette = ["#146a63", "#f2b35d", "#d97757", "#6f8677", "#7d6b91", "#6f8fc2", "#c58d5a", "#3f7f9b", "#9a6f5f", "#5f8c6d"]; const styles = getComputedStyle(document.body); const chartTextColor = styles.getPropertyValue("--text").trim() || "#1d2a33"; const chartGridColor = styles.getPropertyValue("--line").trim() || "rgba(29, 42, 51, 0.08)"; const buildDatasets = (node) => { const datasetPayload = JSON.parse(node.dataset.datasets || "null"); if (Array.isArray(datasetPayload) && datasetPayload.length) { return datasetPayload.map((dataset, index) => { const color = palette[index % palette.length]; return { label: dataset.label, data: (dataset.data || []).map((value) => Number(value)), backgroundColor: `${color}22`, borderColor: color, pointBackgroundColor: color, pointBorderColor: color, borderWidth: 3, borderRadius: 0, tension: 0.32, fill: false, }; }); } const values = JSON.parse(node.dataset.values || "[]").map((value) => Number(value)); const secondaryValues = JSON.parse(node.dataset.secondaryValues || "[]").map((value) => Number(value)); const datasets = [{ label: "Werte", data: values, backgroundColor: palette, borderColor: "#146a63", borderRadius: 12, tension: 0.3, }]; if (secondaryValues.length) { datasets[0].label = "Einkommen"; datasets[0].fill = false; datasets.push({ label: "Kosten", data: secondaryValues, backgroundColor: "#f2b35d", borderColor: "#f2b35d", tension: 0.3, }); } if ((node.dataset.chartType || "bar") === "line") { datasets[0].backgroundColor = "#146a6322"; datasets[0].borderWidth = 3; datasets[0].borderRadius = 0; datasets[0].pointBackgroundColor = "#146a63"; datasets[0].pointBorderColor = "#146a63"; datasets[0].fill = false; } return datasets; }; document.querySelectorAll(".chart").forEach((node) => { if (node.dataset.drilldownSource === "true" || node.dataset.drilldownMounted === "true") { return; } const labels = JSON.parse(node.dataset.labels || "[]"); const indexAxis = node.dataset.indexAxis || "x"; const chartType = node.dataset.chartType || "bar"; const datasets = buildDatasets(node); new Chart(node, { type: chartType, data: { labels, datasets, }, options: { responsive: true, maintainAspectRatio: false, indexAxis, scales: chartType === "pie" || chartType === "doughnut" ? {} : { x: { ticks: { color: chartTextColor }, grid: { color: chartGridColor }, }, y: { ticks: { color: chartTextColor }, grid: { color: chartGridColor }, }, }, plugins: { legend: { display: chartType === "pie" || chartType === "doughnut" || chartType === "line", position: "bottom", labels: { color: chartTextColor, }, }, tooltip: { titleColor: chartTextColor, bodyColor: chartTextColor, }, }, }, }); }); document.querySelectorAll('[data-drilldown-source="true"]').forEach((sourceNode) => { if (sourceNode.dataset.drilldownMounted === "true") { return; } sourceNode.dataset.drilldownMounted = "true"; const rootLabels = JSON.parse(sourceNode.dataset.labels || "[]"); const rootValues = JSON.parse(sourceNode.dataset.values || "[]").map((value) => Number(value)); const detailKeys = JSON.parse(sourceNode.dataset.detailKeys || "[]"); const detailMap = JSON.parse(sourceNode.dataset.detailMap || "{}"); const titleTarget = document.getElementById(sourceNode.dataset.detailTitleTarget); const subtitleTarget = document.getElementById(sourceNode.dataset.detailSubtitleTarget); const backButton = document.getElementById(sourceNode.dataset.detailBackTarget); const rootTitlePayload = document.getElementById(`${sourceNode.id}-root-title`); const rootTitle = rootTitlePayload ? JSON.parse(rootTitlePayload.textContent) : { title: titleTarget ? titleTarget.textContent : "", subtitle: subtitleTarget ? subtitleTarget.textContent : "", }; let isInDetail = false; const chart = new Chart(sourceNode, { type: sourceNode.dataset.chartType || "pie", data: { labels: rootLabels, datasets: [ { label: "Kategorien", data: rootValues, backgroundColor: palette, borderWidth: 0, }, ], }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: true, position: "bottom", labels: { color: chartTextColor, }, }, }, onClick: (_event, elements) => { if (!elements.length || isInDetail) { return; } const clickedIndex = elements[0].index; const detailKey = detailKeys[clickedIndex]; if (detailKey) { renderDetailChart(detailKey); } }, }, }); const renderRootChart = () => { chart.config.type = sourceNode.dataset.chartType || "pie"; chart.data.labels = rootLabels; chart.data.datasets = [ { label: "Kategorien", data: rootValues, backgroundColor: palette, borderWidth: 0, }, ]; chart.options.plugins.legend.display = true; chart.update(); isInDetail = false; if (titleTarget) { titleTarget.textContent = rootTitle.title; } if (subtitleTarget) { subtitleTarget.textContent = rootTitle.subtitle; } if (backButton) { backButton.hidden = true; } }; const renderDetailChart = (detailKey) => { const detail = detailMap[detailKey]; if (!detail) { return; } if (titleTarget) { titleTarget.textContent = detail.label; } if (subtitleTarget) { subtitleTarget.textContent = `${detail.account} · ${detail.labels.length} Einträge`; } chart.config.type = "doughnut"; chart.data.labels = detail.labels; chart.data.datasets = [ { label: detail.label, data: detail.values, backgroundColor: palette, borderWidth: 0, }, ]; chart.options.plugins.legend.display = true; chart.update(); isInDetail = true; if (backButton) { backButton.hidden = false; } }; if (backButton) { backButton.addEventListener("click", () => { renderRootChart(); }); } renderRootChart(); }); } function mountDialogs() { const openDialogById = (dialogId, node = null) => { const dialog = document.getElementById(dialogId); if (!dialog) return; document.querySelectorAll("dialog[open]").forEach((openDialog) => { if (openDialog !== dialog) { openDialog.close(); } }); const accountIdInput = dialog.querySelector("[data-dialog-account-id]"); const areaInput = dialog.querySelector("[data-dialog-area]"); const categoryNameInput = dialog.querySelector("[data-dialog-category-name]"); const returnDialogInput = dialog.querySelector("[data-dialog-return-dialog]"); const placeholderInput = dialog.querySelector("[data-dialog-name-placeholder]"); const communityAccountField = dialog.querySelector("[data-community-account-field]"); if (accountIdInput) { accountIdInput.value = node?.dataset.accountId || ""; } if (areaInput) { areaInput.value = node?.dataset.area || areaInput.defaultValue || areaInput.value; } if (categoryNameInput) { categoryNameInput.value = node?.dataset.categoryName || ""; } if (placeholderInput) { if (!placeholderInput.dataset.defaultPlaceholder) { placeholderInput.dataset.defaultPlaceholder = placeholderInput.placeholder; } placeholderInput.placeholder = node?.dataset.placeholder || placeholderInput.dataset.defaultPlaceholder; } if (returnDialogInput) { returnDialogInput.value = node?.dataset.returnDialog || ""; } if (communityAccountField && areaInput) { communityAccountField.hidden = areaInput.value !== "budget"; } dialog.querySelectorAll("[data-annual-visibility]").forEach((element) => { element.hidden = areaInput ? areaInput.value !== "budget" : false; }); dialog.showModal(); }; document.querySelectorAll("[data-open-dialog]").forEach((node) => { node.addEventListener("click", () => { openDialogById(node.dataset.openDialog, node); }); }); document.querySelectorAll("dialog").forEach((dialog) => { dialog.addEventListener("click", (event) => { const rect = dialog.getBoundingClientRect(); const inside = rect.top <= event.clientY && event.clientY <= rect.bottom && rect.left <= event.clientX && event.clientX <= rect.right; if (!inside) { dialog.close(); } }); }); const initialDialogId = new URLSearchParams(window.location.search).get("dialog"); if (initialDialogId) { openDialogById(initialDialogId); const nextUrl = new URL(window.location.href); nextUrl.searchParams.delete("dialog"); window.history.replaceState({}, "", nextUrl); } } function mountPersonalSplitSync() { const floInputs = document.querySelectorAll('[data-personal-split="flo"]'); const desiInputs = document.querySelectorAll('[data-personal-split="desi"]'); const syncPair = (source, target) => { source.addEventListener("input", () => { const sourceValue = Number(source.value || 0); const boundedValue = Math.max(0, Math.min(100, sourceValue)); target.value = String(Math.max(0, Math.min(100, 100 - boundedValue))); }); }; floInputs.forEach((floInput, index) => { const desiInput = desiInputs[index]; if (!desiInput) return; syncPair(floInput, desiInput); syncPair(desiInput, floInput); }); } function mountAnnualAmountSync() { document.querySelectorAll("[data-annual-sync-wrapper]").forEach((wrapper) => { const monthlyInput = wrapper.querySelector('[data-annual-sync="monthly"]'); const yearlyInput = wrapper.querySelector('[data-annual-sync="yearly"]'); if (!monthlyInput || !yearlyInput) return; let syncing = false; const formatValue = (value) => (Number.isFinite(value) ? value.toFixed(2) : ""); monthlyInput.addEventListener("input", () => { if (syncing) return; syncing = true; const monthlyValue = Number(monthlyInput.value || 0); yearlyInput.value = formatValue(monthlyValue * 12); syncing = false; }); yearlyInput.addEventListener("input", () => { if (syncing) return; syncing = true; const yearlyValue = Number(yearlyInput.value || 0); monthlyInput.value = formatValue(yearlyValue / 12); syncing = false; }); }); document.querySelectorAll('form').forEach((form) => { const annualVisibility = form.querySelector("[data-annual-visibility]"); const areaInput = form.querySelector("[data-dialog-area]"); if (!annualVisibility || !areaInput) return; annualVisibility.hidden = areaInput.value !== "budget"; }); } function mountThemeToggle() { const storageKey = "saldo-theme"; const body = document.body; if (!body) return; const applyTheme = (theme) => { body.dataset.theme = theme; }; const storedTheme = window.localStorage.getItem(storageKey); const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; applyTheme(storedTheme || (prefersDark ? "dark" : "light")); document.querySelectorAll("[data-theme-toggle]").forEach((node) => { node.addEventListener("click", () => { const nextTheme = body.dataset.theme === "dark" ? "light" : "dark"; applyTheme(nextTheme); window.localStorage.setItem(storageKey, nextTheme); }); }); } document.addEventListener("DOMContentLoaded", async () => { await registerServiceWorker(); injectCsrfTokens(); mountThemeToggle(); mountCharts(); mountDialogs(); mountPersonalSplitSync(); mountAnnualAmountSync(); document.querySelectorAll("[data-enable-push]").forEach((node) => { node.addEventListener("click", async (event) => { event.preventDefault(); await enablePushNotifications(); }); }); });