Files
saldo/app/static/js/app.js
T
2026-04-21 21:17:36 +02:00

433 lines
14 KiB
JavaScript

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();
});
});
});