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) {