diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5d17085..64232c2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.11.2] - 2026-04-05
+
+### Added
+- Configurable currency for the budget section: choose from 13 currencies (EUR, USD, GBP, SEK, NOK, DKK, CHF, PLN, CZK, HUF, JPY, AUD, CAD) in Settings → Budget (#20)
+- Currency preference is stored household-wide via the preferences API and applied to all budget amounts and formatting
+
## [0.11.1] - 2026-04-05
### Fixed
diff --git a/package.json b/package.json
index c885bd4..a294e6f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "oikos",
- "version": "0.11.1",
+ "version": "0.11.2",
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
"main": "server/index.js",
"type": "module",
diff --git a/public/locales/de.json b/public/locales/de.json
index d54b7c0..53516ed 100644
--- a/public/locales/de.json
+++ b/public/locales/de.json
@@ -418,7 +418,7 @@
"typeIncome": "Einnahme",
"titleLabel": "Titel *",
"titlePlaceholder": "z.B. REWE Einkauf",
- "amountLabel": "Betrag (€) *",
+ "amountLabel": "Betrag *",
"amountPlaceholder": "0,00",
"categoryLabel": "Kategorie",
"dateLabel": "Datum *",
@@ -518,7 +518,11 @@
"mealTypesLabel": "Sichtbare Mahlzeiten",
"mealTypesHint": "Nur ausgewaehlte Mahlzeit-Typen werden im Essensplan angezeigt.",
"mealTypesSaved": "Essensplan-Einstellungen gespeichert.",
- "mealTypesMinOne": "Mindestens ein Mahlzeit-Typ muss aktiv sein."
+ "mealTypesMinOne": "Mindestens ein Mahlzeit-Typ muss aktiv sein.",
+ "sectionBudget": "Budget",
+ "currencyLabel": "Währung",
+ "currencyHint": "Legt die Währung für den gesamten Budget-Bereich fest.",
+ "currencySaved": "Währung gespeichert."
},
"login": {
diff --git a/public/locales/en.json b/public/locales/en.json
index b12fe20..ccad78f 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -418,7 +418,7 @@
"typeIncome": "Income",
"titleLabel": "Title *",
"titlePlaceholder": "e.g. Supermarket",
- "amountLabel": "Amount (€) *",
+ "amountLabel": "Amount *",
"amountPlaceholder": "0.00",
"categoryLabel": "Category",
"dateLabel": "Date *",
@@ -518,7 +518,11 @@
"mealTypesLabel": "Visible meals",
"mealTypesHint": "Only selected meal types are shown in the meal planner.",
"mealTypesSaved": "Meal plan settings saved.",
- "mealTypesMinOne": "At least one meal type must be active."
+ "mealTypesMinOne": "At least one meal type must be active.",
+ "sectionBudget": "Budget",
+ "currencyLabel": "Currency",
+ "currencyHint": "Sets the currency used throughout the budget section.",
+ "currencySaved": "Currency saved."
},
"login": {
diff --git a/public/locales/it.json b/public/locales/it.json
index dca53e3..9d35c10 100644
--- a/public/locales/it.json
+++ b/public/locales/it.json
@@ -418,7 +418,7 @@
"typeIncome": "Entrata",
"titleLabel": "Titolo *",
"titlePlaceholder": "es. Supermercato",
- "amountLabel": "Importo (€) *",
+ "amountLabel": "Importo *",
"amountPlaceholder": "0,00",
"categoryLabel": "Categoria",
"dateLabel": "Data *",
@@ -518,7 +518,11 @@
"mealTypesLabel": "Pasti visibili",
"mealTypesHint": "Solo i tipi di pasto selezionati vengono mostrati nel piano pasti.",
"mealTypesSaved": "Impostazioni del piano pasti salvate.",
- "mealTypesMinOne": "Almeno un tipo di pasto deve essere attivo."
+ "mealTypesMinOne": "Almeno un tipo di pasto deve essere attivo.",
+ "sectionBudget": "Bilancio",
+ "currencyLabel": "Valuta",
+ "currencyHint": "Imposta la valuta utilizzata in tutta la sezione budget.",
+ "currencySaved": "Valuta salvata."
},
"login": {
diff --git a/public/pages/budget.js b/public/pages/budget.js
index f819afd..a4059a8 100644
--- a/public/pages/budget.js
+++ b/public/pages/budget.js
@@ -47,6 +47,7 @@ let state = {
entries: [],
summary: null,
prevSummary: null, // Vormonat für Monatsvergleich
+ currency: 'EUR',
};
let _container = null;
@@ -55,7 +56,7 @@ let _container = null;
// --------------------------------------------------------
function formatAmount(n) {
- return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n);
+ return new Intl.NumberFormat(getLocale(), { style: 'currency', currency: state.currency }).format(n);
}
function formatMonthLabel(ym) {
@@ -104,6 +105,11 @@ export async function render(container, { user }) {
const today = new Date();
state.month = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`;
+ try {
+ const prefsRes = await api.get('/preferences');
+ state.currency = prefsRes.data?.currency ?? 'EUR';
+ } catch (_) { /* Fallback auf EUR */ }
+
container.innerHTML = `
${t('budget.title')}
diff --git a/public/pages/settings.js b/public/pages/settings.js
index f1277af..a1745df 100644
--- a/public/pages/settings.js
+++ b/public/pages/settings.js
@@ -9,6 +9,21 @@ import { t, formatDate, formatTime } from '/i18n.js';
import { esc } from '/utils/html.js';
import '/components/oikos-locale-picker.js';
+const SUPPORTED_CURRENCIES = ['AUD', 'CAD', 'CHF', 'CZK', 'DKK', 'EUR', 'GBP', 'HUF', 'JPY', 'NOK', 'PLN', 'SEK', 'USD'];
+
+function buildCurrencyOptions(selected) {
+ const display = typeof Intl.DisplayNames !== 'undefined'
+ ? new Intl.DisplayNames([document.documentElement.lang || 'en'], { type: 'currency' })
+ : null;
+ return SUPPORTED_CURRENCIES
+ .map((code) => {
+ const label = display ? `${code} - ${display.of(code)}` : code;
+ const sel = code === selected ? ' selected' : '';
+ return ``;
+ })
+ .join('');
+}
+
/**
* @param {HTMLElement} container
* @param {{ user: object }} context
@@ -23,7 +38,7 @@ export async function render(container, { user }) {
let users = [];
let googleStatus = { configured: false, connected: false, lastSync: null };
let appleStatus = { configured: false, lastSync: null };
- let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'] };
+ let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR' };
try {
const [usersRes, gStatus, aStatus, prefsRes] = await Promise.allSettled([
@@ -114,6 +129,18 @@ export async function render(container, { user }) {
+
+
+ ${t('settings.sectionBudget')}
+
+
${t('settings.currencyLabel')}
+
${t('settings.currencyHint')}
+
+
+
+
${t('settings.sectionAccount')}
@@ -330,6 +357,19 @@ function bindEvents(container, user) {
});
}
+ // Währungs-Auswahl
+ const currencySelect = container.querySelector('#currency-select');
+ if (currencySelect) {
+ currencySelect.addEventListener('change', async () => {
+ try {
+ await api.put('/preferences', { currency: currencySelect.value });
+ window.oikos?.showToast(t('settings.currencySaved'), 'success');
+ } catch (err) {
+ window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
+ }
+ });
+ }
+
// Passwort ändern
const passwordForm = container.querySelector('#password-form');
if (passwordForm) {
diff --git a/server/routes/preferences.js b/server/routes/preferences.js
index e9e53c9..248ed4d 100644
--- a/server/routes/preferences.js
+++ b/server/routes/preferences.js
@@ -15,6 +15,9 @@ const router = express.Router();
const VALID_MEAL_TYPES = ['breakfast', 'lunch', 'dinner', 'snack'];
const DEFAULT_MEAL_TYPES = VALID_MEAL_TYPES.join(',');
+const VALID_CURRENCIES = ['EUR', 'USD', 'GBP', 'SEK', 'NOK', 'DKK', 'CHF', 'PLN', 'CZK', 'HUF', 'JPY', 'AUD', 'CAD'];
+const DEFAULT_CURRENCY = 'EUR';
+
// --------------------------------------------------------
// Hilfsfunktionen
// --------------------------------------------------------
@@ -43,10 +46,12 @@ router.get('/', (req, res) => {
try {
const raw = cfgGet('visible_meal_types') ?? DEFAULT_MEAL_TYPES;
const visibleMealTypes = raw.split(',').filter((t) => VALID_MEAL_TYPES.includes(t));
+ const currency = cfgGet('currency') ?? DEFAULT_CURRENCY;
res.json({
data: {
visible_meal_types: visibleMealTypes,
+ currency,
},
});
} catch (err) {
@@ -64,22 +69,34 @@ router.get('/', (req, res) => {
router.put('/', (req, res) => {
try {
- const { visible_meal_types } = req.body;
+ const { visible_meal_types, currency } = req.body;
- if (!Array.isArray(visible_meal_types)) {
- return res.status(400).json({ error: 'visible_meal_types muss ein Array sein', code: 400 });
+ if (visible_meal_types !== undefined) {
+ if (!Array.isArray(visible_meal_types)) {
+ return res.status(400).json({ error: 'visible_meal_types muss ein Array sein', code: 400 });
+ }
+ const filtered = visible_meal_types.filter((t) => VALID_MEAL_TYPES.includes(t));
+ if (filtered.length === 0) {
+ return res.status(400).json({ error: 'Mindestens ein Mahlzeit-Typ muss aktiv sein', code: 400 });
+ }
+ cfgSet('visible_meal_types', filtered.join(','));
}
- const filtered = visible_meal_types.filter((t) => VALID_MEAL_TYPES.includes(t));
- if (filtered.length === 0) {
- return res.status(400).json({ error: 'Mindestens ein Mahlzeit-Typ muss aktiv sein', code: 400 });
+ if (currency !== undefined) {
+ if (!VALID_CURRENCIES.includes(currency)) {
+ return res.status(400).json({ error: `Ungültige Währung. Erlaubt: ${VALID_CURRENCIES.join(', ')}`, code: 400 });
+ }
+ cfgSet('currency', currency);
}
- cfgSet('visible_meal_types', filtered.join(','));
+ const rawMealTypes = cfgGet('visible_meal_types') ?? DEFAULT_MEAL_TYPES;
+ const savedMealTypes = rawMealTypes.split(',').filter((t) => VALID_MEAL_TYPES.includes(t));
+ const savedCurrency = cfgGet('currency') ?? DEFAULT_CURRENCY;
res.json({
data: {
- visible_meal_types: filtered,
+ visible_meal_types: savedMealTypes,
+ currency: savedCurrency,
},
});
} catch (err) {