feat(budget): configurable currency in settings (#20)
Add household-wide currency preference for the budget section. Users can select from 13 currencies (EUR, USD, GBP, SEK, NOK, DKK, CHF, PLN, CZK, HUF, JPY, AUD, CAD) in Settings → Budget. - preferences API (GET/PUT) now includes currency field - budget page loads currency from preferences on render - formatAmount() uses locale-aware Intl.NumberFormat with chosen currency - settings page gains a Budget section with a currency select - all three locales (de, en, it) updated with new i18n keys
This commit is contained in:
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.11.1] - 2026-04-05
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oikos",
|
"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.",
|
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -418,7 +418,7 @@
|
|||||||
"typeIncome": "Einnahme",
|
"typeIncome": "Einnahme",
|
||||||
"titleLabel": "Titel *",
|
"titleLabel": "Titel *",
|
||||||
"titlePlaceholder": "z.B. REWE Einkauf",
|
"titlePlaceholder": "z.B. REWE Einkauf",
|
||||||
"amountLabel": "Betrag (€) *",
|
"amountLabel": "Betrag *",
|
||||||
"amountPlaceholder": "0,00",
|
"amountPlaceholder": "0,00",
|
||||||
"categoryLabel": "Kategorie",
|
"categoryLabel": "Kategorie",
|
||||||
"dateLabel": "Datum *",
|
"dateLabel": "Datum *",
|
||||||
@@ -518,7 +518,11 @@
|
|||||||
"mealTypesLabel": "Sichtbare Mahlzeiten",
|
"mealTypesLabel": "Sichtbare Mahlzeiten",
|
||||||
"mealTypesHint": "Nur ausgewaehlte Mahlzeit-Typen werden im Essensplan angezeigt.",
|
"mealTypesHint": "Nur ausgewaehlte Mahlzeit-Typen werden im Essensplan angezeigt.",
|
||||||
"mealTypesSaved": "Essensplan-Einstellungen gespeichert.",
|
"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": {
|
"login": {
|
||||||
|
|||||||
@@ -418,7 +418,7 @@
|
|||||||
"typeIncome": "Income",
|
"typeIncome": "Income",
|
||||||
"titleLabel": "Title *",
|
"titleLabel": "Title *",
|
||||||
"titlePlaceholder": "e.g. Supermarket",
|
"titlePlaceholder": "e.g. Supermarket",
|
||||||
"amountLabel": "Amount (€) *",
|
"amountLabel": "Amount *",
|
||||||
"amountPlaceholder": "0.00",
|
"amountPlaceholder": "0.00",
|
||||||
"categoryLabel": "Category",
|
"categoryLabel": "Category",
|
||||||
"dateLabel": "Date *",
|
"dateLabel": "Date *",
|
||||||
@@ -518,7 +518,11 @@
|
|||||||
"mealTypesLabel": "Visible meals",
|
"mealTypesLabel": "Visible meals",
|
||||||
"mealTypesHint": "Only selected meal types are shown in the meal planner.",
|
"mealTypesHint": "Only selected meal types are shown in the meal planner.",
|
||||||
"mealTypesSaved": "Meal plan settings saved.",
|
"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": {
|
"login": {
|
||||||
|
|||||||
@@ -418,7 +418,7 @@
|
|||||||
"typeIncome": "Entrata",
|
"typeIncome": "Entrata",
|
||||||
"titleLabel": "Titolo *",
|
"titleLabel": "Titolo *",
|
||||||
"titlePlaceholder": "es. Supermercato",
|
"titlePlaceholder": "es. Supermercato",
|
||||||
"amountLabel": "Importo (€) *",
|
"amountLabel": "Importo *",
|
||||||
"amountPlaceholder": "0,00",
|
"amountPlaceholder": "0,00",
|
||||||
"categoryLabel": "Categoria",
|
"categoryLabel": "Categoria",
|
||||||
"dateLabel": "Data *",
|
"dateLabel": "Data *",
|
||||||
@@ -518,7 +518,11 @@
|
|||||||
"mealTypesLabel": "Pasti visibili",
|
"mealTypesLabel": "Pasti visibili",
|
||||||
"mealTypesHint": "Solo i tipi di pasto selezionati vengono mostrati nel piano pasti.",
|
"mealTypesHint": "Solo i tipi di pasto selezionati vengono mostrati nel piano pasti.",
|
||||||
"mealTypesSaved": "Impostazioni del piano pasti salvate.",
|
"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": {
|
"login": {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ let state = {
|
|||||||
entries: [],
|
entries: [],
|
||||||
summary: null,
|
summary: null,
|
||||||
prevSummary: null, // Vormonat für Monatsvergleich
|
prevSummary: null, // Vormonat für Monatsvergleich
|
||||||
|
currency: 'EUR',
|
||||||
};
|
};
|
||||||
let _container = null;
|
let _container = null;
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ let _container = null;
|
|||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
function formatAmount(n) {
|
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) {
|
function formatMonthLabel(ym) {
|
||||||
@@ -104,6 +105,11 @@ export async function render(container, { user }) {
|
|||||||
const today = new Date();
|
const today = new Date();
|
||||||
state.month = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`;
|
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 = `
|
container.innerHTML = `
|
||||||
<div class="budget-page">
|
<div class="budget-page">
|
||||||
<h1 class="sr-only">${t('budget.title')}</h1>
|
<h1 class="sr-only">${t('budget.title')}</h1>
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ import { t, formatDate, formatTime } from '/i18n.js';
|
|||||||
import { esc } from '/utils/html.js';
|
import { esc } from '/utils/html.js';
|
||||||
import '/components/oikos-locale-picker.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 `<option value="${code}"${sel}>${label}</option>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {HTMLElement} container
|
* @param {HTMLElement} container
|
||||||
* @param {{ user: object }} context
|
* @param {{ user: object }} context
|
||||||
@@ -23,7 +38,7 @@ export async function render(container, { user }) {
|
|||||||
let users = [];
|
let users = [];
|
||||||
let googleStatus = { configured: false, connected: false, lastSync: null };
|
let googleStatus = { configured: false, connected: false, lastSync: null };
|
||||||
let appleStatus = { configured: 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 {
|
try {
|
||||||
const [usersRes, gStatus, aStatus, prefsRes] = await Promise.allSettled([
|
const [usersRes, gStatus, aStatus, prefsRes] = await Promise.allSettled([
|
||||||
@@ -114,6 +129,18 @@ export async function render(container, { user }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Budget -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2 class="settings-section__title">${t('settings.sectionBudget')}</h2>
|
||||||
|
<div class="settings-card">
|
||||||
|
<h3 class="settings-card__title">${t('settings.currencyLabel')}</h3>
|
||||||
|
<p class="form-hint" style="margin-bottom:var(--space-3)">${t('settings.currencyHint')}</p>
|
||||||
|
<select class="form-input" id="currency-select">
|
||||||
|
${buildCurrencyOptions(prefs.currency)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Mein Konto -->
|
<!-- Mein Konto -->
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h2 class="settings-section__title">${t('settings.sectionAccount')}</h2>
|
<h2 class="settings-section__title">${t('settings.sectionAccount')}</h2>
|
||||||
@@ -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
|
// Passwort ändern
|
||||||
const passwordForm = container.querySelector('#password-form');
|
const passwordForm = container.querySelector('#password-form');
|
||||||
if (passwordForm) {
|
if (passwordForm) {
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ const router = express.Router();
|
|||||||
const VALID_MEAL_TYPES = ['breakfast', 'lunch', 'dinner', 'snack'];
|
const VALID_MEAL_TYPES = ['breakfast', 'lunch', 'dinner', 'snack'];
|
||||||
const DEFAULT_MEAL_TYPES = VALID_MEAL_TYPES.join(',');
|
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
|
// Hilfsfunktionen
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -43,10 +46,12 @@ router.get('/', (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const raw = cfgGet('visible_meal_types') ?? DEFAULT_MEAL_TYPES;
|
const raw = cfgGet('visible_meal_types') ?? DEFAULT_MEAL_TYPES;
|
||||||
const visibleMealTypes = raw.split(',').filter((t) => VALID_MEAL_TYPES.includes(t));
|
const visibleMealTypes = raw.split(',').filter((t) => VALID_MEAL_TYPES.includes(t));
|
||||||
|
const currency = cfgGet('currency') ?? DEFAULT_CURRENCY;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
data: {
|
data: {
|
||||||
visible_meal_types: visibleMealTypes,
|
visible_meal_types: visibleMealTypes,
|
||||||
|
currency,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -64,22 +69,34 @@ router.get('/', (req, res) => {
|
|||||||
|
|
||||||
router.put('/', (req, res) => {
|
router.put('/', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { visible_meal_types } = req.body;
|
const { visible_meal_types, currency } = req.body;
|
||||||
|
|
||||||
|
if (visible_meal_types !== undefined) {
|
||||||
if (!Array.isArray(visible_meal_types)) {
|
if (!Array.isArray(visible_meal_types)) {
|
||||||
return res.status(400).json({ error: 'visible_meal_types muss ein Array sein', code: 400 });
|
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));
|
const filtered = visible_meal_types.filter((t) => VALID_MEAL_TYPES.includes(t));
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
return res.status(400).json({ error: 'Mindestens ein Mahlzeit-Typ muss aktiv sein', code: 400 });
|
return res.status(400).json({ error: 'Mindestens ein Mahlzeit-Typ muss aktiv sein', code: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
cfgSet('visible_meal_types', filtered.join(','));
|
cfgSet('visible_meal_types', filtered.join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
res.json({
|
||||||
data: {
|
data: {
|
||||||
visible_meal_types: filtered,
|
visible_meal_types: savedMealTypes,
|
||||||
|
currency: savedCurrency,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user