release: publish saldo 0.1.0
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user