Initial kostenverdeler webapp

This commit is contained in:
Codex
2026-06-19 11:41:39 +02:00
commit aae84ec9b6
5 changed files with 783 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.DS_Store
Thumbs.db

16
README.md Normal file
View File

@@ -0,0 +1,16 @@
# Kostenverdeler
Een eenvoudige webapplicatie waarmee twee personen gezamenlijke kosten kunnen bijhouden en automatisch kunnen zien wie nog welk bedrag moet betalen om gelijk uit te komen.
## Gebruik
Open `index.html` in een browser. De app bewaart gegevens lokaal in de browser via `localStorage`.
## Functionaliteit
- namen van twee personen aanpassen
- uitgaven toevoegen met omschrijving, bedrag en betaler
- kosten verdelen over beide personen of over een specifieke persoon
- automatisch afrekenadvies
- uitgaven verwijderen
- CSV-export

271
app.js Normal file
View File

@@ -0,0 +1,271 @@
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();

110
index.html Normal file
View File

@@ -0,0 +1,110 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kostenverdeler</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main class="app-shell">
<section class="topbar" aria-labelledby="app-title">
<div>
<p class="eyebrow">Gezamenlijke uitgaven</p>
<h1 id="app-title">Kostenverdeler</h1>
</div>
<button class="icon-button subtle" id="resetButton" type="button" title="Alles wissen" aria-label="Alles wissen">
<span aria-hidden="true">&#8634;</span>
</button>
</section>
<section class="summary-grid" aria-label="Samenvatting">
<article class="summary-card">
<span class="summary-label">Totaal uitgegeven</span>
<strong id="totalSpent">&euro; 0,00</strong>
</article>
<article class="summary-card">
<span class="summary-label" id="personALabel">Persoon 1 betaalde</span>
<strong id="paidA">&euro; 0,00</strong>
</article>
<article class="summary-card">
<span class="summary-label" id="personBLabel">Persoon 2 betaalde</span>
<strong id="paidB">&euro; 0,00</strong>
</article>
</section>
<section class="balance-panel" aria-live="polite">
<div>
<p class="eyebrow">Af te rekenen</p>
<h2 id="settlementText">Alles is in balans.</h2>
</div>
<p id="balanceDetail">Voeg een uitgave toe om de verdeling te berekenen.</p>
</section>
<div class="content-grid">
<section class="panel setup-panel" aria-labelledby="people-title">
<div class="section-heading">
<h2 id="people-title">Personen</h2>
</div>
<div class="name-grid">
<label>
<span>Persoon 1</span>
<input id="personAName" type="text" autocomplete="off" maxlength="32">
</label>
<label>
<span>Persoon 2</span>
<input id="personBName" type="text" autocomplete="off" maxlength="32">
</label>
</div>
</section>
<section class="panel form-panel" aria-labelledby="expense-title">
<div class="section-heading">
<h2 id="expense-title">Uitgave toevoegen</h2>
</div>
<form id="expenseForm" class="expense-form">
<label class="full-row">
<span>Omschrijving</span>
<input id="description" type="text" placeholder="Boodschappen, huur, lunch..." required maxlength="80">
</label>
<label>
<span>Bedrag</span>
<input id="amount" type="text" inputmode="decimal" placeholder="0,00" required>
</label>
<label>
<span>Betaald door</span>
<select id="paidBy"></select>
</label>
<fieldset class="full-row">
<legend>Verdelen over</legend>
<div class="split-options">
<label>
<input id="shareA" type="checkbox" checked>
<span id="shareALabel">Persoon 1</span>
</label>
<label>
<input id="shareB" type="checkbox" checked>
<span id="shareBLabel">Persoon 2</span>
</label>
</div>
</fieldset>
<button class="primary-button full-row" type="submit">Uitgave opslaan</button>
</form>
</section>
<section class="panel expenses-panel" aria-labelledby="list-title">
<div class="section-heading row-heading">
<h2 id="list-title">Uitgaven</h2>
<button class="text-button" id="exportButton" type="button">CSV</button>
</div>
<div id="emptyState" class="empty-state">
Nog geen uitgaven. Begin met de eerste gedeelde kostenpost.
</div>
<ul id="expenseList" class="expense-list" aria-label="Uitgavenlijst"></ul>
</section>
</div>
</main>
<script src="app.js"></script>
</body>
</html>

384
styles.css Normal file
View File

@@ -0,0 +1,384 @@
:root {
color-scheme: light;
--ink: #18201f;
--muted: #66706c;
--line: #d9dfdc;
--paper: #fbfbf8;
--panel: #ffffff;
--accent: #166b5c;
--accent-dark: #0f4d42;
--accent-soft: #dff1eb;
--warn-soft: #fff0d6;
--shadow: 0 18px 50px rgba(24, 32, 31, 0.09);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
linear-gradient(135deg, rgba(22, 107, 92, 0.08), transparent 35%),
linear-gradient(315deg, rgba(210, 79, 58, 0.08), transparent 32%),
var(--paper);
color: var(--ink);
}
button,
input,
select {
font: inherit;
}
.app-shell {
width: min(1120px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 42px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 10px 0 22px;
}
.eyebrow {
margin: 0 0 6px;
color: var(--accent);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 0;
font-size: clamp(2rem, 6vw, 4.4rem);
line-height: 0.95;
}
h2 {
margin-bottom: 0;
font-size: 1.15rem;
}
.icon-button,
.text-button,
.primary-button {
border: 0;
cursor: pointer;
}
.icon-button {
display: grid;
width: 44px;
height: 44px;
place-items: center;
border-radius: 8px;
}
.subtle {
background: var(--panel);
color: var(--muted);
box-shadow: inset 0 0 0 1px var(--line);
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.summary-card,
.panel,
.balance-panel {
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(255, 255, 255, 0.86);
box-shadow: var(--shadow);
}
.summary-card {
padding: 18px;
}
.summary-label,
label span,
legend {
color: var(--muted);
font-size: 0.86rem;
font-weight: 700;
}
.summary-card strong {
display: block;
margin-top: 8px;
font-size: 1.65rem;
}
.balance-panel {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 22px;
margin: 14px 0;
padding: 22px;
background: linear-gradient(135deg, var(--accent-soft), #fff);
}
.balance-panel h2 {
font-size: clamp(1.45rem, 3vw, 2.2rem);
}
.balance-panel p:last-child {
max-width: 420px;
margin-bottom: 0;
color: var(--muted);
line-height: 1.45;
}
.content-grid {
display: grid;
grid-template-columns: minmax(280px, 0.85fr) minmax(420px, 1.15fr);
gap: 14px;
align-items: start;
}
.panel {
padding: 18px;
}
.setup-panel {
grid-column: 1;
}
.form-panel {
grid-column: 1;
}
.expenses-panel {
grid-column: 2;
grid-row: 1 / span 2;
}
.section-heading {
margin-bottom: 16px;
}
.row-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.name-grid,
.expense-form {
display: grid;
gap: 14px;
}
.expense-form {
grid-template-columns: 1fr 1fr;
}
.full-row {
grid-column: 1 / -1;
}
label {
display: grid;
gap: 7px;
}
input,
select {
width: 100%;
min-height: 44px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
color: var(--ink);
padding: 10px 12px;
outline: none;
}
input:focus,
select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(22, 107, 92, 0.14);
}
fieldset {
margin: 0;
padding: 0;
border: 0;
}
legend {
margin-bottom: 8px;
}
.split-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.split-options label {
display: flex;
min-height: 44px;
align-items: center;
gap: 10px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px 12px;
background: #fff;
}
.split-options input {
width: 18px;
min-height: 18px;
accent-color: var(--accent);
}
.primary-button {
min-height: 48px;
border-radius: 8px;
background: var(--accent);
color: #fff;
font-weight: 800;
}
.primary-button:hover {
background: var(--accent-dark);
}
.text-button {
min-height: 36px;
border-radius: 8px;
background: var(--accent-soft);
color: var(--accent-dark);
padding: 0 12px;
font-weight: 800;
}
.empty-state {
border: 1px dashed var(--line);
border-radius: 8px;
color: var(--muted);
padding: 20px;
text-align: center;
}
.expense-list {
display: grid;
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
}
.expense-item {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 14px;
align-items: center;
border: 1px solid var(--line);
border-radius: 8px;
padding: 13px;
background: #fff;
}
.expense-title {
margin-bottom: 4px;
font-weight: 850;
overflow-wrap: anywhere;
}
.expense-meta {
margin-bottom: 0;
color: var(--muted);
font-size: 0.88rem;
}
.expense-amount {
font-weight: 900;
white-space: nowrap;
}
.delete-button {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border: 0;
border-radius: 8px;
background: #f7ded8;
color: #8b2f20;
cursor: pointer;
}
.warn {
background: var(--warn-soft);
}
@media (max-width: 820px) {
.summary-grid,
.content-grid,
.expense-form,
.split-options {
grid-template-columns: 1fr;
}
.expenses-panel,
.setup-panel,
.form-panel {
grid-column: auto;
grid-row: auto;
}
.balance-panel {
display: block;
}
.balance-panel p:last-child {
margin-top: 12px;
}
}
@media (max-width: 520px) {
.app-shell {
width: min(100% - 20px, 1120px);
padding-top: 14px;
}
.topbar {
align-items: flex-start;
}
.summary-card,
.panel,
.balance-panel {
padding: 14px;
}
.expense-item {
grid-template-columns: 1fr auto;
}
.expense-amount {
grid-column: 1;
}
}