Updated expense splitter to safe state in browser.

This commit is contained in:
2026-08-28 17:10:40 +02:00
parent b20f42161e
commit 21f39d103b
+205 -100
View File
@@ -1,31 +1,41 @@
<!DOCTYPE html> <!doctype html>
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<title>Expense Splitter</title> <title>Expense Splitter</title>
<link rel="stylesheet" type="text/css" href="/static/web/main.css" /> <link rel="stylesheet" type="text/css" href="/static/web/main.css" />
</head> </head>
<body> <body>
<h1>Expense Splitter</h1> <h1>Expense Splitter</h1>
<hr>
<h2>1. Mitglieder</h2> <h2>1. Mitglieder</h2>
<div id="member-list"></div> <div id="member-list"></div>
<input type="text" id="member-name" placeholder="Name eingeben"> <input type="text" id="member-name" placeholder="Name eingeben" />
<button onclick="addMember()">Mitglied hinzufügen</button> <button onclick="addMember()">Mitglied hinzufügen</button>
<hr> <hr />
<h2>2. Neue Ausgabe hinzufügen</h2> <h2>2. Neue Ausgabe hinzufügen</h2>
<table id="expense-form-table" border="1" cellpadding="4"> <table id="expense-form-table" border="1" cellpadding="4">
<tr> <tr>
<td>Beschreibung:</td> <td>Beschreibung:</td>
<td><input type="text" id="expense-desc" placeholder="z.B. Abendessen"></td> <td>
<input
type="text"
id="expense-desc"
placeholder="z.B. Abendessen"
/>
</td>
</tr> </tr>
<tr> <tr>
<td>Betrag:</td> <td>Betrag:</td>
<td><input type="number" id="expense-amount" step="0.01" placeholder="0.00"></td> <td>
<input
type="number"
id="expense-amount"
step="0.01"
placeholder="0.00"
/>
</td>
</tr> </tr>
<tr> <tr>
<td>Bezahlt von:</td> <td>Bezahlt von:</td>
@@ -35,7 +45,9 @@
<td>Aufteilungsart:</td> <td>Aufteilungsart:</td>
<td> <td>
<select id="split-type" onchange="renderSplitInputs()"> <select id="split-type" onchange="renderSplitInputs()">
<option value="equal">Gleichmäßig unter Ausgewählten</option> <option value="equal">
Gleichmäßig unter Ausgewählten
</option>
<option value="exact">Exakte Beträge pro Person</option> <option value="exact">Exakte Beträge pro Person</option>
</select> </select>
</td> </td>
@@ -46,9 +58,9 @@
</tr> </tr>
</table> </table>
<button onclick="addExpense()">Ausgabe speichern</button> <button onclick="addExpense()">Ausgabe speichern</button>
<div id="expense-error" style="color:red;"></div> <div id="expense-error" style="color: red"></div>
<hr> <hr />
<h2>3. Alle Ausgaben</h2> <h2>3. Alle Ausgaben</h2>
<table id="expense-table" border="1" cellpadding="4"> <table id="expense-table" border="1" cellpadding="4">
@@ -65,63 +77,112 @@
<tbody id="expense-tbody"></tbody> <tbody id="expense-tbody"></tbody>
</table> </table>
<hr> <hr />
<h2>4. Salden (wer hat wie viel zu bekommen / zu zahlen)</h2> <h2>4. Salden (wer hat wie viel zu bekommen / zu zahlen)</h2>
<div id="balances"></div> <div id="balances"></div>
<hr> <hr />
<h2>5. Vereinfachte Rückzahlungen (wer zahlt wem)</h2> <h2>5. Vereinfachte Rückzahlungen (wer zahlt wem)</h2>
<div id="settlements"></div> <div id="settlements"></div>
<script> <script>
// ---- Datenmodell (nur im Speicher, kein localStorage) ---- // ---- Datenmodell (wird im Browser via localStorage gespeichert) ----
const STORAGE_KEY = "expense-splitter-data";
let members = []; // ["Anna", "Ben", ...] let members = []; // ["Anna", "Ben", ...]
let expenses = []; // {id, desc, amount, payer, shares: {name: betrag}} let expenses = []; // {id, desc, amount, payer, shares: {name: betrag}}
let nextExpenseId = 1; let nextExpenseId = 1;
function loadData() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const data = JSON.parse(raw);
members = data.members || [];
expenses = data.expenses || [];
nextExpenseId = data.nextExpenseId || 1;
} catch (e) {
console.error("Konnte gespeicherte Daten nicht laden:", e);
}
}
function saveData() {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ members, expenses, nextExpenseId }),
);
} catch (e) {
console.error("Konnte Daten nicht speichern:", e);
}
}
function resetData() {
if (!confirm("Wirklich alle gespeicherten Daten löschen?"))
return;
members = [];
expenses = [];
nextExpenseId = 1;
localStorage.removeItem(STORAGE_KEY);
renderMembers();
renderPayerSelect();
renderSplitInputs();
renderExpenses();
renderBalances();
}
// ---- Mitglieder verwalten ---- // ---- Mitglieder verwalten ----
function addMember() { function addMember() {
const input = document.getElementById('member-name'); const input = document.getElementById("member-name");
const name = input.value.trim(); const name = input.value.trim();
if (!name) return; if (!name) return;
if (members.includes(name)) { if (members.includes(name)) {
alert('Dieses Mitglied existiert bereits.'); alert("Dieses Mitglied existiert bereits.");
return; return;
} }
members.push(name); members.push(name);
input.value = ''; input.value = "";
saveData();
renderMembers(); renderMembers();
renderPayerSelect(); renderPayerSelect();
renderSplitInputs(); renderSplitInputs();
} }
function removeMember(name) { function removeMember(name) {
const usedInExpense = expenses.some(e => e.payer === name || Object.keys(e.shares).includes(name)); const usedInExpense = expenses.some(
(e) =>
e.payer === name ||
Object.keys(e.shares).includes(name),
);
if (usedInExpense) { if (usedInExpense) {
alert('Dieses Mitglied wird bereits in einer Ausgabe verwendet und kann nicht entfernt werden.'); alert(
"Dieses Mitglied wird bereits in einer Ausgabe verwendet und kann nicht entfernt werden.",
);
return; return;
} }
members = members.filter(m => m !== name); members = members.filter((m) => m !== name);
saveData();
renderMembers(); renderMembers();
renderPayerSelect(); renderPayerSelect();
renderSplitInputs(); renderSplitInputs();
} }
function renderMembers() { function renderMembers() {
const container = document.getElementById('member-list'); const container = document.getElementById("member-list");
container.innerHTML = ''; container.innerHTML = "";
if (members.length === 0) { if (members.length === 0) {
container.innerHTML = '<i>Noch keine Mitglieder hinzugefügt.</i>'; container.innerHTML =
"<i>Noch keine Mitglieder hinzugefügt.</i>";
return; return;
} }
const ul = document.createElement('ul'); const ul = document.createElement("ul");
members.forEach(name => { members.forEach((name) => {
const li = document.createElement('li'); const li = document.createElement("li");
li.textContent = name + ' '; li.textContent = name + " ";
const btn = document.createElement('button'); const btn = document.createElement("button");
btn.textContent = 'Entfernen'; btn.textContent = "Entfernen";
btn.onclick = () => removeMember(name); btn.onclick = () => removeMember(name);
li.appendChild(btn); li.appendChild(btn);
ul.appendChild(li); ul.appendChild(li);
@@ -130,10 +191,10 @@
} }
function renderPayerSelect() { function renderPayerSelect() {
const select = document.getElementById('expense-payer'); const select = document.getElementById("expense-payer");
select.innerHTML = ''; select.innerHTML = "";
members.forEach(name => { members.forEach((name) => {
const opt = document.createElement('option'); const opt = document.createElement("option");
opt.value = name; opt.value = name;
opt.textContent = name; opt.textContent = name;
select.appendChild(opt); select.appendChild(opt);
@@ -142,81 +203,114 @@
// ---- Aufteilungs-Eingaben ---- // ---- Aufteilungs-Eingaben ----
function renderSplitInputs() { function renderSplitInputs() {
const container = document.getElementById('split-inputs'); const container = document.getElementById("split-inputs");
const type = document.getElementById('split-type').value; const type = document.getElementById("split-type").value;
container.innerHTML = ''; container.innerHTML = "";
if (members.length === 0) { if (members.length === 0) {
container.innerHTML = '<i>Bitte zuerst Mitglieder hinzufügen.</i>'; container.innerHTML =
"<i>Bitte zuerst Mitglieder hinzufügen.</i>";
return; return;
} }
members.forEach(name => { members.forEach((name) => {
const wrapper = document.createElement('span'); const wrapper = document.createElement("span");
wrapper.style.marginRight = '10px'; wrapper.style.marginRight = "10px";
if (type === 'equal') { if (type === "equal") {
const checkbox = document.createElement('input'); const checkbox = document.createElement("input");
checkbox.type = 'checkbox'; checkbox.type = "checkbox";
checkbox.checked = true; checkbox.checked = true;
checkbox.id = 'split-check-' + name; checkbox.id = "split-check-" + name;
wrapper.appendChild(checkbox); wrapper.appendChild(checkbox);
const label = document.createElement('label'); const label = document.createElement("label");
label.htmlFor = checkbox.id; label.htmlFor = checkbox.id;
label.textContent = ' ' + name; label.textContent = " " + name;
wrapper.appendChild(label); wrapper.appendChild(label);
} else { } else {
const label = document.createElement('label'); const label = document.createElement("label");
label.textContent = name + ': '; label.textContent = name + ": ";
wrapper.appendChild(label); wrapper.appendChild(label);
const input = document.createElement('input'); const input = document.createElement("input");
input.type = 'number'; input.type = "number";
input.step = '0.01'; input.step = "0.01";
input.placeholder = '0.00'; input.placeholder = "0.00";
input.id = 'split-exact-' + name; input.id = "split-exact-" + name;
wrapper.appendChild(input); wrapper.appendChild(input);
} }
container.appendChild(wrapper); container.appendChild(wrapper);
container.appendChild(document.createElement('br')); container.appendChild(document.createElement("br"));
}); });
} }
// ---- Ausgabe hinzufügen ---- // ---- Ausgabe hinzufügen ----
function addExpense() { function addExpense() {
const errorDiv = document.getElementById('expense-error'); const errorDiv = document.getElementById("expense-error");
errorDiv.textContent = ''; errorDiv.textContent = "";
const desc = document.getElementById('expense-desc').value.trim(); const desc = document
const amount = parseFloat(document.getElementById('expense-amount').value); .getElementById("expense-desc")
const payer = document.getElementById('expense-payer').value; .value.trim();
const type = document.getElementById('split-type').value; const amount = parseFloat(
document.getElementById("expense-amount").value,
);
const payer = document.getElementById("expense-payer").value;
const type = document.getElementById("split-type").value;
if (!desc) { errorDiv.textContent = 'Bitte eine Beschreibung eingeben.'; return; } if (!desc) {
if (!amount || amount <= 0) { errorDiv.textContent = 'Bitte einen gültigen Betrag eingeben.'; return; } errorDiv.textContent = "Bitte eine Beschreibung eingeben.";
if (!payer) { errorDiv.textContent = 'Bitte auswählen, wer bezahlt hat.'; return; } return;
if (members.length === 0) { errorDiv.textContent = 'Bitte zuerst Mitglieder hinzufügen.'; return; } }
if (!amount || amount <= 0) {
errorDiv.textContent =
"Bitte einen gültigen Betrag eingeben.";
return;
}
if (!payer) {
errorDiv.textContent = "Bitte auswählen, wer bezahlt hat.";
return;
}
if (members.length === 0) {
errorDiv.textContent =
"Bitte zuerst Mitglieder hinzufügen.";
return;
}
let shares = {}; let shares = {};
if (type === 'equal') { if (type === "equal") {
const selected = members.filter(name => { const selected = members.filter((name) => {
const cb = document.getElementById('split-check-' + name); const cb = document.getElementById(
"split-check-" + name,
);
return cb && cb.checked; return cb && cb.checked;
}); });
if (selected.length === 0) { errorDiv.textContent = 'Bitte mindestens eine Person für die Aufteilung auswählen.'; return; } if (selected.length === 0) {
errorDiv.textContent =
"Bitte mindestens eine Person für die Aufteilung auswählen.";
return;
}
const perPerson = amount / selected.length; const perPerson = amount / selected.length;
selected.forEach(name => { shares[name] = perPerson; }); selected.forEach((name) => {
shares[name] = perPerson;
});
} else { } else {
let sum = 0; let sum = 0;
members.forEach(name => { members.forEach((name) => {
const input = document.getElementById('split-exact-' + name); const input = document.getElementById(
"split-exact-" + name,
);
const val = parseFloat(input.value); const val = parseFloat(input.value);
if (!isNaN(val) && val > 0) { if (!isNaN(val) && val > 0) {
shares[name] = val; shares[name] = val;
sum += val; sum += val;
} }
}); });
if (Object.keys(shares).length === 0) { errorDiv.textContent = 'Bitte mindestens einen Betrag eingeben.'; return; } if (Object.keys(shares).length === 0) {
errorDiv.textContent =
"Bitte mindestens einen Betrag eingeben.";
return;
}
if (Math.abs(sum - amount) > 0.01) { if (Math.abs(sum - amount) > 0.01) {
errorDiv.textContent = `Die Summe der Aufteilung (${sum.toFixed(2)}) entspricht nicht dem Gesamtbetrag (${amount.toFixed(2)}).`; errorDiv.textContent = `Die Summe der Aufteilung (${sum.toFixed(2)}) entspricht nicht dem Gesamtbetrag (${amount.toFixed(2)}).`;
return; return;
@@ -225,38 +319,40 @@
expenses.push({ expenses.push({
id: nextExpenseId++, id: nextExpenseId++,
date: new Date().toLocaleDateString('de-DE'), date: new Date().toLocaleDateString("de-DE"),
desc: desc, desc: desc,
amount: amount, amount: amount,
payer: payer, payer: payer,
shares: shares shares: shares,
}); });
// Formular zurücksetzen // Formular zurücksetzen
document.getElementById('expense-desc').value = ''; document.getElementById("expense-desc").value = "";
document.getElementById('expense-amount').value = ''; document.getElementById("expense-amount").value = "";
renderSplitInputs(); renderSplitInputs();
saveData();
renderExpenses(); renderExpenses();
renderBalances(); renderBalances();
} }
function deleteExpense(id) { function deleteExpense(id) {
expenses = expenses.filter(e => e.id !== id); expenses = expenses.filter((e) => e.id !== id);
saveData();
renderExpenses(); renderExpenses();
renderBalances(); renderBalances();
} }
// ---- Ausgaben anzeigen ---- // ---- Ausgaben anzeigen ----
function renderExpenses() { function renderExpenses() {
const tbody = document.getElementById('expense-tbody'); const tbody = document.getElementById("expense-tbody");
tbody.innerHTML = ''; tbody.innerHTML = "";
expenses.forEach(e => { expenses.forEach((e) => {
const tr = document.createElement('tr'); const tr = document.createElement("tr");
const splitText = Object.entries(e.shares) const splitText = Object.entries(e.shares)
.map(([name, amt]) => `${name}: ${amt.toFixed(2)}`) .map(([name, amt]) => `${name}: ${amt.toFixed(2)}`)
.join(', '); .join(", ");
tr.innerHTML = ` tr.innerHTML = `
<td>${e.date}</td> <td>${e.date}</td>
@@ -273,9 +369,11 @@
// ---- Salden berechnen ---- // ---- Salden berechnen ----
function calculateBalances() { function calculateBalances() {
const balances = {}; const balances = {};
members.forEach(name => { balances[name] = 0; }); members.forEach((name) => {
balances[name] = 0;
});
expenses.forEach(e => { expenses.forEach((e) => {
// Zahler hat den vollen Betrag ausgelegt // Zahler hat den vollen Betrag ausgelegt
if (balances[e.payer] === undefined) balances[e.payer] = 0; if (balances[e.payer] === undefined) balances[e.payer] = 0;
balances[e.payer] += e.amount; balances[e.payer] += e.amount;
@@ -291,20 +389,20 @@
} }
function renderBalances() { function renderBalances() {
const container = document.getElementById('balances'); const container = document.getElementById("balances");
const balances = calculateBalances(); const balances = calculateBalances();
container.innerHTML = ''; container.innerHTML = "";
if (members.length === 0) { if (members.length === 0) {
container.innerHTML = '<i>Keine Mitglieder vorhanden.</i>'; container.innerHTML = "<i>Keine Mitglieder vorhanden.</i>";
renderSettlements(balances); renderSettlements(balances);
return; return;
} }
const ul = document.createElement('ul'); const ul = document.createElement("ul");
members.forEach(name => { members.forEach((name) => {
const val = balances[name] || 0; const val = balances[name] || 0;
const li = document.createElement('li'); const li = document.createElement("li");
if (val > 0.005) { if (val > 0.005) {
li.textContent = `${name} bekommt ${val.toFixed(2)}`; li.textContent = `${name} bekommt ${val.toFixed(2)}`;
} else if (val < -0.005) { } else if (val < -0.005) {
@@ -321,8 +419,8 @@
// ---- Schulden vereinfachen (greedy Algorithmus) ---- // ---- Schulden vereinfachen (greedy Algorithmus) ----
function renderSettlements(balances) { function renderSettlements(balances) {
const container = document.getElementById('settlements'); const container = document.getElementById("settlements");
container.innerHTML = ''; container.innerHTML = "";
let debtors = []; // schulden Geld (negativer Saldo) let debtors = []; // schulden Geld (negativer Saldo)
let creditors = []; // bekommen Geld (positiver Saldo) let creditors = []; // bekommen Geld (positiver Saldo)
@@ -333,14 +431,19 @@
}); });
const settlements = []; const settlements = [];
let i = 0, j = 0; let i = 0,
j = 0;
while (i < debtors.length && j < creditors.length) { while (i < debtors.length && j < creditors.length) {
const debtor = debtors[i]; const debtor = debtors[i];
const creditor = creditors[j]; const creditor = creditors[j];
const amount = Math.min(debtor.amount, creditor.amount); const amount = Math.min(debtor.amount, creditor.amount);
settlements.push({ from: debtor.name, to: creditor.name, amount }); settlements.push({
from: debtor.name,
to: creditor.name,
amount,
});
debtor.amount -= amount; debtor.amount -= amount;
creditor.amount -= amount; creditor.amount -= amount;
@@ -350,20 +453,22 @@
} }
if (settlements.length === 0) { if (settlements.length === 0) {
container.innerHTML = '<i>Keine offenen Zahlungen. Alles ausgeglichen!</i>'; container.innerHTML =
"<i>Keine offenen Zahlungen. Alles ausgeglichen!</i>";
return; return;
} }
const ul = document.createElement('ul'); const ul = document.createElement("ul");
settlements.forEach(s => { settlements.forEach((s) => {
const li = document.createElement('li'); const li = document.createElement("li");
li.textContent = `${s.from} zahlt ${s.amount.toFixed(2)} an ${s.to}`; li.textContent = `${s.from} zahlt ${s.amount.toFixed(2)} an ${s.to}`;
ul.appendChild(li); ul.appendChild(li);
}); });
container.appendChild(ul); container.appendChild(ul);
} }
// ---- Initiales Rendering ---- // ---- Initiales Laden und Rendering ----
loadData();
renderMembers(); renderMembers();
renderPayerSelect(); renderPayerSelect();
renderSplitInputs(); renderSplitInputs();