Files
kostenverdeler/app.js
2026-06-19 11:41:39 +02:00

272 lines
8.0 KiB
JavaScript

const storageKey = "kostenverdeler:v1";
const defaultState = {
people: ["Persoon 1", "Persoon 2"],
expenses: []
};
const els = {
personAName: document.querySelector("#personAName"),
personBName: document.querySelector("#personBName"),
personALabel: document.querySelector("#personALabel"),
personBLabel: document.querySelector("#personBLabel"),
shareALabel: document.querySelector("#shareALabel"),
shareBLabel: document.querySelector("#shareBLabel"),
paidBy: document.querySelector("#paidBy"),
form: document.querySelector("#expenseForm"),
description: document.querySelector("#description"),
amount: document.querySelector("#amount"),
shareA: document.querySelector("#shareA"),
shareB: document.querySelector("#shareB"),
totalSpent: document.querySelector("#totalSpent"),
paidA: document.querySelector("#paidA"),
paidB: document.querySelector("#paidB"),
settlementText: document.querySelector("#settlementText"),
balanceDetail: document.querySelector("#balanceDetail"),
emptyState: document.querySelector("#emptyState"),
expenseList: document.querySelector("#expenseList"),
resetButton: document.querySelector("#resetButton"),
exportButton: document.querySelector("#exportButton")
};
let state = loadState();
function loadState() {
try {
const saved = JSON.parse(localStorage.getItem(storageKey));
if (!saved || !Array.isArray(saved.people) || !Array.isArray(saved.expenses)) {
return structuredClone(defaultState);
}
return saved;
} catch {
return structuredClone(defaultState);
}
}
function saveState() {
localStorage.setItem(storageKey, JSON.stringify(state));
}
function money(value) {
return new Intl.NumberFormat("nl-NL", {
style: "currency",
currency: "EUR"
}).format(value);
}
function cleanName(value, fallback) {
return value.trim() || fallback;
}
function calculate() {
const totals = {
paid: [0, 0],
owes: [0, 0],
total: 0
};
for (const expense of state.expenses) {
totals.total += expense.amount;
totals.paid[expense.paidBy] += expense.amount;
const participants = expense.participants.length ? expense.participants : [0, 1];
const share = expense.amount / participants.length;
for (const personIndex of participants) {
totals.owes[personIndex] += share;
}
}
return totals;
}
function renderPeople() {
const [a, b] = state.people;
els.personAName.value = a;
els.personBName.value = b;
els.personALabel.textContent = `${a} betaalde`;
els.personBLabel.textContent = `${b} betaalde`;
els.shareALabel.textContent = a;
els.shareBLabel.textContent = b;
els.paidBy.innerHTML = "";
state.people.forEach((name, index) => {
const option = document.createElement("option");
option.value = String(index);
option.textContent = name;
els.paidBy.append(option);
});
}
function renderSummary() {
const totals = calculate();
els.totalSpent.textContent = money(totals.total);
els.paidA.textContent = money(totals.paid[0]);
els.paidB.textContent = money(totals.paid[1]);
const balances = totals.paid.map((paid, index) => paid - totals.owes[index]);
const cents = Math.round(Math.abs(balances[0]) * 100);
if (state.expenses.length === 0) {
els.settlementText.textContent = "Alles is in balans.";
els.balanceDetail.textContent = "Voeg een uitgave toe om de verdeling te berekenen.";
return;
}
if (cents === 0) {
els.settlementText.textContent = "Alles is in balans.";
els.balanceDetail.textContent = "Beide personen dragen precies evenveel bij aan hun eigen aandeel.";
return;
}
const receiver = balances[0] > 0 ? 0 : 1;
const payer = receiver === 0 ? 1 : 0;
const amount = Math.abs(balances[receiver]);
els.settlementText.textContent = `${state.people[payer]} betaalt ${money(amount)} aan ${state.people[receiver]}.`;
els.balanceDetail.textContent = `${state.people[receiver]} heeft meer voorgeschoten dan diens aandeel. Na deze betaling staan jullie gelijk.`;
}
function renderExpenses() {
els.expenseList.innerHTML = "";
els.emptyState.hidden = state.expenses.length > 0;
const fragment = document.createDocumentFragment();
[...state.expenses].reverse().forEach((expense) => {
const item = document.createElement("li");
item.className = "expense-item";
const details = document.createElement("div");
const title = document.createElement("p");
title.className = "expense-title";
title.textContent = expense.description;
const participants = expense.participants.map((index) => state.people[index]).join(" + ");
const meta = document.createElement("p");
meta.className = "expense-meta";
meta.textContent = `Betaald door ${state.people[expense.paidBy]} - verdeeld over ${participants}`;
const amount = document.createElement("div");
amount.className = "expense-amount";
amount.textContent = money(expense.amount);
const button = document.createElement("button");
button.className = "delete-button";
button.type = "button";
button.title = "Verwijderen";
button.setAttribute("aria-label", `${expense.description} verwijderen`);
button.textContent = "x";
button.addEventListener("click", () => {
state.expenses = state.expenses.filter((itemExpense) => itemExpense.id !== expense.id);
saveState();
render();
});
details.append(title, meta);
item.append(details, amount, button);
fragment.append(item);
});
els.expenseList.append(fragment);
}
function render() {
renderPeople();
renderSummary();
renderExpenses();
}
function updateNames() {
state.people = [
cleanName(els.personAName.value, "Persoon 1"),
cleanName(els.personBName.value, "Persoon 2")
];
saveState();
render();
}
function parseAmount(value) {
return Number(value.replace(",", "."));
}
function createId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
els.personAName.addEventListener("change", updateNames);
els.personBName.addEventListener("change", updateNames);
els.form.addEventListener("submit", (event) => {
event.preventDefault();
const participants = [];
if (els.shareA.checked) participants.push(0);
if (els.shareB.checked) participants.push(1);
if (participants.length === 0) {
els.form.classList.add("warn");
setTimeout(() => els.form.classList.remove("warn"), 500);
return;
}
const amount = parseAmount(els.amount.value);
if (!Number.isFinite(amount) || amount <= 0) return;
const description = els.description.value.trim();
if (!description) {
els.description.focus();
return;
}
state.expenses.push({
id: createId(),
description,
amount: Math.round(amount * 100) / 100,
paidBy: Number(els.paidBy.value),
participants,
createdAt: new Date().toISOString()
});
saveState();
els.form.reset();
els.shareA.checked = true;
els.shareB.checked = true;
render();
els.description.focus();
});
els.resetButton.addEventListener("click", () => {
const confirmed = window.confirm("Weet je zeker dat je alle namen en uitgaven wilt wissen?");
if (!confirmed) return;
state = structuredClone(defaultState);
saveState();
render();
});
els.exportButton.addEventListener("click", () => {
const header = ["Omschrijving", "Bedrag", "Betaald door", "Verdeeld over", "Datum"];
const rows = state.expenses.map((expense) => [
expense.description,
expense.amount.toFixed(2).replace(".", ","),
state.people[expense.paidBy],
expense.participants.map((index) => state.people[index]).join(" + "),
new Date(expense.createdAt).toLocaleString("nl-NL")
]);
const csv = [header, ...rows]
.map((row) => row.map((cell) => `"${String(cell).replaceAll('"', '""')}"`).join(";"))
.join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "kostenverdeler.csv";
link.click();
URL.revokeObjectURL(url);
});
render();