diff --git a/CHANGELOG.md b/CHANGELOG.md index e2d4a45..80e02e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.23.17] - 2026-04-25 - -### Fixed -- Italian (it) locale: translated all missing strings in the recipes section (`nav.recipes`, `meals.savedRecipeLabel`, `meals.savedRecipePlaceholder`, `meals.saveAsRecipe`, `meals.recipeScaleLabel`, and all `recipes.*` keys) — contributed by @albanobattistella - ## [0.23.16] - 2026-04-24 ### Changed diff --git a/package-lock.json b/package-lock.json index 814c504..4235aea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oikos", - "version": "0.23.17", + "version": "0.23.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oikos", - "version": "0.23.17", + "version": "0.23.16", "license": "MIT", "dependencies": { "bcrypt": "^6.0.0", diff --git a/package.json b/package.json index 41b5b8f..806b999 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.23.17", + "version": "0.23.16", "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/ar.json b/public/locales/ar.json index e8e5cbf..a6b8a9d 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -463,21 +463,68 @@ "trendNeutral": "- مثل {{month}}", "validAmountRequired": "أدخل مبلغاً صحيحاً", "dateRequired": "التاريخ مطلوب", - "catFood": "الطعام", + "catFood": "Food", "catRent": "الإيجار", "catInsurance": "التأمين", "catMobility": "التنقل", - "catLeisure": "الترفيه", + "catLeisure": "Leisure and Entertainment", "catClothing": "الملابس", "catHealth": "الصحة", - "catEducation": "التعليم", + "catEducation": "Education", "catMisc": "متنوع", "catEarnedIncome": "دخل العمل", "catInvestmentIncome": "دخل الاستثمار", "catTransferGiftIncome": "التحويلات والهدايا", "catGovernmentBenefits": "المزايا الاجتماعية", "catOtherIncome": "دخل آخر", - "loadingIndicator": "جارٍ التحميل…" + "loadingIndicator": "جارٍ التحميل…", + "subcategoryLabel": "Subcategory", + "catHousing": "Housing / Home", + "catTransport": "Transport", + "catPersonalHealth": "Personal Care / Health", + "catShoppingClothing": "Shopping and Clothing", + "catFinancialOther": "Financial Services and Other", + "subcatRentMortgage": "Rent / Mortgage", + "subcatCondominium": "Condominium fees", + "subcatUtilities": "Electricity / Water / Gas", + "subcatInternetTvPhone": "Internet / TV / Phone", + "subcatRenovationMaintenance": "Renovation / Maintenance", + "subcatCleaning": "Cleaning", + "subcatGroceries": "Groceries", + "subcatRestaurantsBars": "Restaurants / Bars", + "subcatSnacksFastFood": "Snacks / Fast Food", + "subcatBakery": "Bakery", + "subcatFuel": "Fuel", + "subcatParkingTolls": "Parking / Tolls", + "subcatPublicTransport": "Public transport", + "subcatAppsTaxi": "Apps / Taxi", + "subcatMaintenanceInsurance": "Maintenance / Insurance", + "subcatPharmacy": "Pharmacy", + "subcatHealthInsurance": "Health insurance", + "subcatGymSports": "Gym / Sports", + "subcatBeautyCosmetics": "Beauty / Cosmetics", + "subcatTravel": "Travel", + "subcatStreaming": "Streaming", + "subcatEvents": "Events", + "subcatHobbies": "Hobbies", + "subcatClothesShoes": "Clothes / Shoes", + "subcatElectronics": "Electronics", + "subcatGifts": "Gifts", + "subcatCoursesCollege": "Courses / College", + "subcatSchoolSupplies": "School supplies", + "subcatLanguages": "Languages", + "subcatLoansInterest": "Loans / Interest", + "subcatBankFees": "Bank fees", + "subcatInsuranceOther": "Insurance", + "subcatInvestments": "Investments", + "subcatTaxes": "Taxes", + "metaLoadError": "Budget categories could not be loaded.", + "addCategory": "+ category", + "addSubcategory": "+ subcategory", + "newCategoryPrompt": "Name of the new category:", + "newSubcategoryPrompt": "Name of the new subcategory:", + "categoryAddedToast": "Category added.", + "subcategoryAddedToast": "Subcategory added." }, "settings": { "title": "الإعدادات", diff --git a/public/locales/de.json b/public/locales/de.json index d78d2a9..b6b6501 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -469,11 +469,11 @@ "trendNeutral": "- wie {{month}}", "validAmountRequired": "Gültigen Betrag eingeben", "dateRequired": "Datum ist erforderlich", - "catFood": "Lebensmittel", + "catFood": "Ernährung", "catRent": "Miete", "catInsurance": "Versicherung", "catMobility": "Mobilität", - "catLeisure": "Freizeit", + "catLeisure": "Freizeit und Unterhaltung", "catClothing": "Kleidung", "catHealth": "Gesundheit", "catEducation": "Bildung", @@ -483,7 +483,54 @@ "catTransferGiftIncome": "Geschenke & Transfers", "catGovernmentBenefits": "Sozialleistungen", "catOtherIncome": "Sonstiges Einkommen", - "loadingIndicator": "Lade…" + "loadingIndicator": "Lade…", + "subcategoryLabel": "Unterkategorie", + "catHousing": "Wohnen / Zuhause", + "catTransport": "Transport", + "catPersonalHealth": "Körperpflege / Gesundheit", + "catShoppingClothing": "Einkäufe und Kleidung", + "catFinancialOther": "Finanzdienstleistungen und Sonstiges", + "subcatRentMortgage": "Miete / Kreditrate", + "subcatCondominium": "Hausgeld", + "subcatUtilities": "Strom / Wasser / Gas", + "subcatInternetTvPhone": "Internet / TV / Telefon", + "subcatRenovationMaintenance": "Renovierung / Instandhaltung", + "subcatCleaning": "Reinigung", + "subcatGroceries": "Supermarkt", + "subcatRestaurantsBars": "Restaurants / Bars", + "subcatSnacksFastFood": "Snacks / Fast Food", + "subcatBakery": "Bäckerei", + "subcatFuel": "Kraftstoff", + "subcatParkingTolls": "Parken / Maut", + "subcatPublicTransport": "Öffentliche Verkehrsmittel", + "subcatAppsTaxi": "Apps / Taxi", + "subcatMaintenanceInsurance": "Wartung / Versicherung", + "subcatPharmacy": "Apotheke", + "subcatHealthInsurance": "Krankenversicherung", + "subcatGymSports": "Fitnessstudio / Sport", + "subcatBeautyCosmetics": "Schönheit / Kosmetik", + "subcatTravel": "Reisen", + "subcatStreaming": "Streaming", + "subcatEvents": "Veranstaltungen", + "subcatHobbies": "Hobbys", + "subcatClothesShoes": "Kleidung / Schuhe", + "subcatElectronics": "Elektronik", + "subcatGifts": "Geschenke", + "subcatCoursesCollege": "Kurse / Hochschule", + "subcatSchoolSupplies": "Schulmaterial", + "subcatLanguages": "Sprachen", + "subcatLoansInterest": "Kredite / Zinsen", + "subcatBankFees": "Bankgebühren", + "subcatInsuranceOther": "Versicherungen", + "subcatInvestments": "Investitionen", + "subcatTaxes": "Steuern", + "metaLoadError": "Budget-Kategorien konnten nicht geladen werden.", + "addCategory": "+ Kategorie", + "addSubcategory": "+ Unterkategorie", + "newCategoryPrompt": "Name der neuen Kategorie:", + "newSubcategoryPrompt": "Name der neuen Unterkategorie:", + "categoryAddedToast": "Kategorie hinzugefügt.", + "subcategoryAddedToast": "Unterkategorie hinzugefügt." }, "settings": { "title": "Einstellungen", diff --git a/public/locales/el.json b/public/locales/el.json index 5ac786d..fbfc4f8 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -463,21 +463,68 @@ "trendNeutral": "- ίδιο με {{month}}", "validAmountRequired": "Παρακαλώ εισαγάγετε έγκυρο ποσό", "dateRequired": "Η ημερομηνία είναι υποχρεωτική", - "catFood": "Τρόφιμα", + "catFood": "Food", "catRent": "Ενοίκιο", "catInsurance": "Ασφάλεια", "catMobility": "Μεταφορές", - "catLeisure": "Ελεύθερος χρόνος", + "catLeisure": "Leisure and Entertainment", "catClothing": "Ρουχισμός", "catHealth": "Υγεία", - "catEducation": "Εκπαίδευση", + "catEducation": "Education", "catMisc": "Διάφορα", "catEarnedIncome": "Εισόδημα από εργασία", "catInvestmentIncome": "Επενδυτικό εισόδημα", "catTransferGiftIncome": "Μεταφορές και δώρα", "catGovernmentBenefits": "Κοινωνικές παροχές", "catOtherIncome": "Άλλο εισόδημα", - "loadingIndicator": "Φόρτωση…" + "loadingIndicator": "Φόρτωση…", + "subcategoryLabel": "Subcategory", + "catHousing": "Housing / Home", + "catTransport": "Transport", + "catPersonalHealth": "Personal Care / Health", + "catShoppingClothing": "Shopping and Clothing", + "catFinancialOther": "Financial Services and Other", + "subcatRentMortgage": "Rent / Mortgage", + "subcatCondominium": "Condominium fees", + "subcatUtilities": "Electricity / Water / Gas", + "subcatInternetTvPhone": "Internet / TV / Phone", + "subcatRenovationMaintenance": "Renovation / Maintenance", + "subcatCleaning": "Cleaning", + "subcatGroceries": "Groceries", + "subcatRestaurantsBars": "Restaurants / Bars", + "subcatSnacksFastFood": "Snacks / Fast Food", + "subcatBakery": "Bakery", + "subcatFuel": "Fuel", + "subcatParkingTolls": "Parking / Tolls", + "subcatPublicTransport": "Public transport", + "subcatAppsTaxi": "Apps / Taxi", + "subcatMaintenanceInsurance": "Maintenance / Insurance", + "subcatPharmacy": "Pharmacy", + "subcatHealthInsurance": "Health insurance", + "subcatGymSports": "Gym / Sports", + "subcatBeautyCosmetics": "Beauty / Cosmetics", + "subcatTravel": "Travel", + "subcatStreaming": "Streaming", + "subcatEvents": "Events", + "subcatHobbies": "Hobbies", + "subcatClothesShoes": "Clothes / Shoes", + "subcatElectronics": "Electronics", + "subcatGifts": "Gifts", + "subcatCoursesCollege": "Courses / College", + "subcatSchoolSupplies": "School supplies", + "subcatLanguages": "Languages", + "subcatLoansInterest": "Loans / Interest", + "subcatBankFees": "Bank fees", + "subcatInsuranceOther": "Insurance", + "subcatInvestments": "Investments", + "subcatTaxes": "Taxes", + "metaLoadError": "Budget categories could not be loaded.", + "addCategory": "+ category", + "addSubcategory": "+ subcategory", + "newCategoryPrompt": "Name of the new category:", + "newSubcategoryPrompt": "Name of the new subcategory:", + "categoryAddedToast": "Category added.", + "subcategoryAddedToast": "Subcategory added." }, "settings": { "title": "Ρυθμίσεις", diff --git a/public/locales/en.json b/public/locales/en.json index f81727a..b72af7f 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -463,11 +463,11 @@ "trendNeutral": "- same as {{month}}", "validAmountRequired": "Please enter a valid amount", "dateRequired": "Date is required", - "catFood": "Groceries", + "catFood": "Food", "catRent": "Rent", "catInsurance": "Insurance", "catMobility": "Transport", - "catLeisure": "Leisure", + "catLeisure": "Leisure and Entertainment", "catClothing": "Clothing", "catHealth": "Health", "catEducation": "Education", @@ -477,7 +477,54 @@ "catTransferGiftIncome": "Transfer & Gift Income", "catGovernmentBenefits": "Government & Social Benefits", "catOtherIncome": "Other Income", - "loadingIndicator": "Loading…" + "loadingIndicator": "Loading…", + "subcategoryLabel": "Subcategory", + "catHousing": "Housing / Home", + "catTransport": "Transport", + "catPersonalHealth": "Personal Care / Health", + "catShoppingClothing": "Shopping and Clothing", + "catFinancialOther": "Financial Services and Other", + "subcatRentMortgage": "Rent / Mortgage", + "subcatCondominium": "Condominium fees", + "subcatUtilities": "Electricity / Water / Gas", + "subcatInternetTvPhone": "Internet / TV / Phone", + "subcatRenovationMaintenance": "Renovation / Maintenance", + "subcatCleaning": "Cleaning", + "subcatGroceries": "Groceries", + "subcatRestaurantsBars": "Restaurants / Bars", + "subcatSnacksFastFood": "Snacks / Fast Food", + "subcatBakery": "Bakery", + "subcatFuel": "Fuel", + "subcatParkingTolls": "Parking / Tolls", + "subcatPublicTransport": "Public transport", + "subcatAppsTaxi": "Apps / Taxi", + "subcatMaintenanceInsurance": "Maintenance / Insurance", + "subcatPharmacy": "Pharmacy", + "subcatHealthInsurance": "Health insurance", + "subcatGymSports": "Gym / Sports", + "subcatBeautyCosmetics": "Beauty / Cosmetics", + "subcatTravel": "Travel", + "subcatStreaming": "Streaming", + "subcatEvents": "Events", + "subcatHobbies": "Hobbies", + "subcatClothesShoes": "Clothes / Shoes", + "subcatElectronics": "Electronics", + "subcatGifts": "Gifts", + "subcatCoursesCollege": "Courses / College", + "subcatSchoolSupplies": "School supplies", + "subcatLanguages": "Languages", + "subcatLoansInterest": "Loans / Interest", + "subcatBankFees": "Bank fees", + "subcatInsuranceOther": "Insurance", + "subcatInvestments": "Investments", + "subcatTaxes": "Taxes", + "metaLoadError": "Budget categories could not be loaded.", + "addCategory": "+ category", + "addSubcategory": "+ subcategory", + "newCategoryPrompt": "Name of the new category:", + "newSubcategoryPrompt": "Name of the new subcategory:", + "categoryAddedToast": "Category added.", + "subcategoryAddedToast": "Subcategory added." }, "settings": { "title": "Settings", diff --git a/public/locales/es.json b/public/locales/es.json index b3faf96..3a1e8c9 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -467,7 +467,7 @@ "catRent": "Alquiler", "catInsurance": "Seguro", "catMobility": "Movilidad", - "catLeisure": "Ocio", + "catLeisure": "Ocio y entretenimiento", "catClothing": "Ropa", "catHealth": "Salud", "catEducation": "Educación", @@ -477,7 +477,54 @@ "catTransferGiftIncome": "Transferencias y Regalos", "catGovernmentBenefits": "Prestaciones Sociales", "catOtherIncome": "Otros Ingresos", - "loadingIndicator": "Cargando…" + "loadingIndicator": "Cargando…", + "subcategoryLabel": "Subcategoría", + "catHousing": "Vivienda / Casa", + "catTransport": "Transporte", + "catPersonalHealth": "Cuidado personal / Salud", + "catShoppingClothing": "Compras y ropa", + "catFinancialOther": "Servicios financieros y otros", + "subcatRentMortgage": "Alquiler / Hipoteca", + "subcatCondominium": "Comunidad", + "subcatUtilities": "Luz / Agua / Gas", + "subcatInternetTvPhone": "Internet / TV / Teléfono", + "subcatRenovationMaintenance": "Reforma / Mantenimiento", + "subcatCleaning": "Limpieza", + "subcatGroceries": "Supermercado", + "subcatRestaurantsBars": "Restaurantes / Bares", + "subcatSnacksFastFood": "Snacks / Comida rápida", + "subcatBakery": "Panadería", + "subcatFuel": "Combustible", + "subcatParkingTolls": "Aparcamiento / Peajes", + "subcatPublicTransport": "Transporte público", + "subcatAppsTaxi": "Apps / Taxi", + "subcatMaintenanceInsurance": "Mantenimiento / Seguro", + "subcatPharmacy": "Farmacia", + "subcatHealthInsurance": "Seguro médico", + "subcatGymSports": "Gimnasio / Deportes", + "subcatBeautyCosmetics": "Belleza / Cosmética", + "subcatTravel": "Viajes", + "subcatStreaming": "Streaming", + "subcatEvents": "Eventos", + "subcatHobbies": "Aficiones", + "subcatClothesShoes": "Ropa / Calzado", + "subcatElectronics": "Electrónica", + "subcatGifts": "Regalos", + "subcatCoursesCollege": "Cursos / Universidad", + "subcatSchoolSupplies": "Material escolar", + "subcatLanguages": "Idiomas", + "subcatLoansInterest": "Préstamos / Intereses", + "subcatBankFees": "Comisiones bancarias", + "subcatInsuranceOther": "Seguros", + "subcatInvestments": "Inversiones", + "subcatTaxes": "Impuestos", + "metaLoadError": "No se pudieron cargar las categorías del presupuesto.", + "addCategory": "+ categoría", + "addSubcategory": "+ subcategoría", + "newCategoryPrompt": "Nombre de la nueva categoría:", + "newSubcategoryPrompt": "Nombre de la nueva subcategoría:", + "categoryAddedToast": "Categoría añadida.", + "subcategoryAddedToast": "Subcategoría añadida." }, "settings": { "title": "Ajustes", diff --git a/public/locales/fr.json b/public/locales/fr.json index 583be21..af83829 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -467,7 +467,7 @@ "catRent": "Loyer", "catInsurance": "Assurance", "catMobility": "Transport", - "catLeisure": "Loisirs", + "catLeisure": "Loisirs et divertissement", "catClothing": "Vêtements", "catHealth": "Santé", "catEducation": "Éducation", @@ -477,7 +477,54 @@ "catTransferGiftIncome": "Transferts et Cadeaux", "catGovernmentBenefits": "Allocations Sociales", "catOtherIncome": "Autres Revenus", - "loadingIndicator": "Chargement…" + "loadingIndicator": "Chargement…", + "subcategoryLabel": "Sous-catégorie", + "catHousing": "Logement / Maison", + "catTransport": "Transport", + "catPersonalHealth": "Soins personnels / Santé", + "catShoppingClothing": "Achats et vêtements", + "catFinancialOther": "Services financiers et autres", + "subcatRentMortgage": "Loyer / Crédit immobilier", + "subcatCondominium": "Copropriété", + "subcatUtilities": "Électricité / Eau / Gaz", + "subcatInternetTvPhone": "Internet / TV / Téléphone", + "subcatRenovationMaintenance": "Rénovation / Entretien", + "subcatCleaning": "Nettoyage", + "subcatGroceries": "Supermarché", + "subcatRestaurantsBars": "Restaurants / Bars", + "subcatSnacksFastFood": "Snacks / Fast-food", + "subcatBakery": "Boulangerie", + "subcatFuel": "Carburant", + "subcatParkingTolls": "Parking / Péages", + "subcatPublicTransport": "Transports publics", + "subcatAppsTaxi": "Apps / Taxi", + "subcatMaintenanceInsurance": "Entretien / Assurance", + "subcatPharmacy": "Pharmacie", + "subcatHealthInsurance": "Assurance santé", + "subcatGymSports": "Salle de sport / Sports", + "subcatBeautyCosmetics": "Beauté / Cosmétiques", + "subcatTravel": "Voyages", + "subcatStreaming": "Streaming", + "subcatEvents": "Événements", + "subcatHobbies": "Loisirs", + "subcatClothesShoes": "Vêtements / Chaussures", + "subcatElectronics": "Électronique", + "subcatGifts": "Cadeaux", + "subcatCoursesCollege": "Cours / Université", + "subcatSchoolSupplies": "Fournitures scolaires", + "subcatLanguages": "Langues", + "subcatLoansInterest": "Prêts / Intérêts", + "subcatBankFees": "Frais bancaires", + "subcatInsuranceOther": "Assurances", + "subcatInvestments": "Investissements", + "subcatTaxes": "Impôts", + "metaLoadError": "Impossible de charger les catégories du budget.", + "addCategory": "+ catégorie", + "addSubcategory": "+ sous-catégorie", + "newCategoryPrompt": "Nom de la nouvelle catégorie :", + "newSubcategoryPrompt": "Nom de la nouvelle sous-catégorie :", + "categoryAddedToast": "Catégorie ajoutée.", + "subcategoryAddedToast": "Sous-catégorie ajoutée." }, "settings": { "title": "Paramètres", diff --git a/public/locales/hi.json b/public/locales/hi.json index be3288f..70d6c99 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -463,21 +463,68 @@ "trendNeutral": "- {{month}} जैसा", "validAmountRequired": "वैध राशि दर्ज करें", "dateRequired": "तारीख आवश्यक है", - "catFood": "भोजन", + "catFood": "Food", "catRent": "किराया", "catInsurance": "बीमा", "catMobility": "परिवहन", - "catLeisure": "मनोरंजन", + "catLeisure": "Leisure and Entertainment", "catClothing": "कपड़े", "catHealth": "स्वास्थ्य", - "catEducation": "शिक्षा", + "catEducation": "Education", "catMisc": "विविध", "catEarnedIncome": "कमाई आय", "catInvestmentIncome": "निवेश आय", "catTransferGiftIncome": "स्थानांतरण और उपहार", "catGovernmentBenefits": "सामाजिक लाभ", "catOtherIncome": "अन्य आय", - "loadingIndicator": "लोड हो रहा है…" + "loadingIndicator": "लोड हो रहा है…", + "subcategoryLabel": "Subcategory", + "catHousing": "Housing / Home", + "catTransport": "Transport", + "catPersonalHealth": "Personal Care / Health", + "catShoppingClothing": "Shopping and Clothing", + "catFinancialOther": "Financial Services and Other", + "subcatRentMortgage": "Rent / Mortgage", + "subcatCondominium": "Condominium fees", + "subcatUtilities": "Electricity / Water / Gas", + "subcatInternetTvPhone": "Internet / TV / Phone", + "subcatRenovationMaintenance": "Renovation / Maintenance", + "subcatCleaning": "Cleaning", + "subcatGroceries": "Groceries", + "subcatRestaurantsBars": "Restaurants / Bars", + "subcatSnacksFastFood": "Snacks / Fast Food", + "subcatBakery": "Bakery", + "subcatFuel": "Fuel", + "subcatParkingTolls": "Parking / Tolls", + "subcatPublicTransport": "Public transport", + "subcatAppsTaxi": "Apps / Taxi", + "subcatMaintenanceInsurance": "Maintenance / Insurance", + "subcatPharmacy": "Pharmacy", + "subcatHealthInsurance": "Health insurance", + "subcatGymSports": "Gym / Sports", + "subcatBeautyCosmetics": "Beauty / Cosmetics", + "subcatTravel": "Travel", + "subcatStreaming": "Streaming", + "subcatEvents": "Events", + "subcatHobbies": "Hobbies", + "subcatClothesShoes": "Clothes / Shoes", + "subcatElectronics": "Electronics", + "subcatGifts": "Gifts", + "subcatCoursesCollege": "Courses / College", + "subcatSchoolSupplies": "School supplies", + "subcatLanguages": "Languages", + "subcatLoansInterest": "Loans / Interest", + "subcatBankFees": "Bank fees", + "subcatInsuranceOther": "Insurance", + "subcatInvestments": "Investments", + "subcatTaxes": "Taxes", + "metaLoadError": "Budget categories could not be loaded.", + "addCategory": "+ category", + "addSubcategory": "+ subcategory", + "newCategoryPrompt": "Name of the new category:", + "newSubcategoryPrompt": "Name of the new subcategory:", + "categoryAddedToast": "Category added.", + "subcategoryAddedToast": "Subcategory added." }, "settings": { "title": "सेटिंग्स", diff --git a/public/locales/it.json b/public/locales/it.json index 13ad9c4..8824541 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -43,7 +43,7 @@ "main": "Navigazione principale", "navigation": "Navigazione", "quickActions": "Azioni rapide", - "recipes": "Ricette", + "recipes": "Recipes", "more": "Altro" }, "dashboard": { @@ -255,10 +255,10 @@ "recipeUrlLabel": "Link ricetta (opzionale)", "recipeUrlPlaceholder": "https://…", "openRecipe": "Apri ricetta", - "savedRecipeLabel": "Ricette salvate", - "savedRecipePlaceholder": "Seleziona ricetta", - "saveAsRecipe": "Salva come ricetta", - "recipeScaleLabel": "Scala ingredienti" + "savedRecipeLabel": "Saved recipe", + "savedRecipePlaceholder": "Select recipe", + "saveAsRecipe": "Save as recipe", + "recipeScaleLabel": "Scale ingredients" }, "calendar": { "title": "Calendario", @@ -463,11 +463,11 @@ "trendNeutral": "- come {{month}}", "validAmountRequired": "Inserisci un importo valido", "dateRequired": "La data è obbligatoria", - "catFood": "Spesa alimentare", + "catFood": "Alimentazione", "catRent": "Affitto", "catInsurance": "Assicurazione", "catMobility": "Trasporti", - "catLeisure": "Tempo libero", + "catLeisure": "Tempo libero e intrattenimento", "catClothing": "Abbigliamento", "catHealth": "Salute", "catEducation": "Istruzione", @@ -477,7 +477,54 @@ "catTransferGiftIncome": "Trasferimenti e Regali", "catGovernmentBenefits": "Prestazioni Sociali", "catOtherIncome": "Altro Reddito", - "loadingIndicator": "Caricamento…" + "loadingIndicator": "Caricamento…", + "subcategoryLabel": "Sottocategoria", + "catHousing": "Abitazione / Casa", + "catTransport": "Trasporti", + "catPersonalHealth": "Cura personale / Salute", + "catShoppingClothing": "Acquisti e abbigliamento", + "catFinancialOther": "Servizi finanziari e altro", + "subcatRentMortgage": "Affitto / Mutuo", + "subcatCondominium": "Condominio", + "subcatUtilities": "Luce / Acqua / Gas", + "subcatInternetTvPhone": "Internet / TV / Telefono", + "subcatRenovationMaintenance": "Ristrutturazione / Manutenzione", + "subcatCleaning": "Pulizia", + "subcatGroceries": "Supermercato", + "subcatRestaurantsBars": "Ristoranti / Bar", + "subcatSnacksFastFood": "Snack / Fast food", + "subcatBakery": "Panetteria", + "subcatFuel": "Carburante", + "subcatParkingTolls": "Parcheggio / Pedaggi", + "subcatPublicTransport": "Trasporto pubblico", + "subcatAppsTaxi": "App / Taxi", + "subcatMaintenanceInsurance": "Manutenzione / Assicurazione", + "subcatPharmacy": "Farmacia", + "subcatHealthInsurance": "Assicurazione sanitaria", + "subcatGymSports": "Palestra / Sport", + "subcatBeautyCosmetics": "Bellezza / Cosmetici", + "subcatTravel": "Viaggi", + "subcatStreaming": "Streaming", + "subcatEvents": "Eventi", + "subcatHobbies": "Hobby", + "subcatClothesShoes": "Vestiti / Scarpe", + "subcatElectronics": "Elettronica", + "subcatGifts": "Regali", + "subcatCoursesCollege": "Corsi / Università", + "subcatSchoolSupplies": "Materiale scolastico", + "subcatLanguages": "Lingue", + "subcatLoansInterest": "Prestiti / Interessi", + "subcatBankFees": "Commissioni bancarie", + "subcatInsuranceOther": "Assicurazioni", + "subcatInvestments": "Investimenti", + "subcatTaxes": "Imposte", + "metaLoadError": "Impossibile caricare le categorie del budget.", + "addCategory": "+ categoria", + "addSubcategory": "+ sottocategoria", + "newCategoryPrompt": "Nome della nuova categoria:", + "newSubcategoryPrompt": "Nome della nuova sottocategoria:", + "categoryAddedToast": "Categoria aggiunta.", + "subcategoryAddedToast": "Sottocategoria aggiunta." }, "settings": { "title": "Impostazioni", @@ -659,28 +706,28 @@ "unitMonths": "mesi" }, "recipes": { - "title": "Ricette", - "addRecipe": "Aggiungi ricetta", - "editRecipe": "Modifica ricetta", - "emptyTitle": "Nessuna ricetta", - "emptyDescription": "Salva le tue ricette preferite e riutilizzale nella pianificazione dei pasti.", - "titleLabel": "Titolo *", - "titlePlaceholder": "es. Pasta Carbonara", - "notesLabel": "Note", - "notesPlaceholder": "Opzionale...", - "urlLabel": "Link della ricetta", + "title": "Recipes", + "addRecipe": "Add recipe", + "editRecipe": "Edit recipe", + "emptyTitle": "No recipes yet", + "emptyDescription": "Save your favorite recipes and reuse them in meal planning.", + "titleLabel": "Title *", + "titlePlaceholder": "e.g. Pasta Carbonara", + "notesLabel": "Notes", + "notesPlaceholder": "Optional...", + "urlLabel": "Recipe link", "urlPlaceholder": "https://...", - "ingredientsLabel": "Ingredienti", - "addToMeals": "Aggiungi al piano alimentare", - "openLink": "Apri il link della ricetta", - "deleteConfirm": "Eliminare la ricetta \"{{title}}\"?", - "created": "Ricetta salvata.", - "updated": "Ricetta aggiornata.", - "deleted": "Ricetta eliminata.", - "titleRequired": "È richiesto il titolo", - "duplicate": "Duplicato", - "duplicated": "Ricetta duplicata.", - "copySuffix": "copia" + "ingredientsLabel": "Ingredients", + "addToMeals": "Add to meal plan", + "openLink": "Open recipe link", + "deleteConfirm": "Delete recipe \"{{title}}\"?", + "created": "Recipe saved.", + "updated": "Recipe updated.", + "deleted": "Recipe deleted.", + "titleRequired": "Title is required", + "duplicate": "Duplicate", + "duplicated": "Recipe duplicated.", + "copySuffix": "copy" }, "search": { "title": "Ricerca", diff --git a/public/locales/ja.json b/public/locales/ja.json index d799c7e..806f6d7 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -463,21 +463,68 @@ "trendNeutral": "- {{month}} と同じ", "validAmountRequired": "有効な金額を入力してください", "dateRequired": "日付は必須です", - "catFood": "食費", + "catFood": "Food", "catRent": "家賃", "catInsurance": "保険", "catMobility": "交通費", - "catLeisure": "娯楽", + "catLeisure": "Leisure and Entertainment", "catClothing": "衣服", "catHealth": "医療", - "catEducation": "教育", + "catEducation": "Education", "catMisc": "その他", "catEarnedIncome": "給与・報酬", "catInvestmentIncome": "投資収入", "catTransferGiftIncome": "譲渡・贈与", "catGovernmentBenefits": "社会保障給付", "catOtherIncome": "その他の収入", - "loadingIndicator": "読み込み中…" + "loadingIndicator": "読み込み中…", + "subcategoryLabel": "Subcategory", + "catHousing": "Housing / Home", + "catTransport": "Transport", + "catPersonalHealth": "Personal Care / Health", + "catShoppingClothing": "Shopping and Clothing", + "catFinancialOther": "Financial Services and Other", + "subcatRentMortgage": "Rent / Mortgage", + "subcatCondominium": "Condominium fees", + "subcatUtilities": "Electricity / Water / Gas", + "subcatInternetTvPhone": "Internet / TV / Phone", + "subcatRenovationMaintenance": "Renovation / Maintenance", + "subcatCleaning": "Cleaning", + "subcatGroceries": "Groceries", + "subcatRestaurantsBars": "Restaurants / Bars", + "subcatSnacksFastFood": "Snacks / Fast Food", + "subcatBakery": "Bakery", + "subcatFuel": "Fuel", + "subcatParkingTolls": "Parking / Tolls", + "subcatPublicTransport": "Public transport", + "subcatAppsTaxi": "Apps / Taxi", + "subcatMaintenanceInsurance": "Maintenance / Insurance", + "subcatPharmacy": "Pharmacy", + "subcatHealthInsurance": "Health insurance", + "subcatGymSports": "Gym / Sports", + "subcatBeautyCosmetics": "Beauty / Cosmetics", + "subcatTravel": "Travel", + "subcatStreaming": "Streaming", + "subcatEvents": "Events", + "subcatHobbies": "Hobbies", + "subcatClothesShoes": "Clothes / Shoes", + "subcatElectronics": "Electronics", + "subcatGifts": "Gifts", + "subcatCoursesCollege": "Courses / College", + "subcatSchoolSupplies": "School supplies", + "subcatLanguages": "Languages", + "subcatLoansInterest": "Loans / Interest", + "subcatBankFees": "Bank fees", + "subcatInsuranceOther": "Insurance", + "subcatInvestments": "Investments", + "subcatTaxes": "Taxes", + "metaLoadError": "Budget categories could not be loaded.", + "addCategory": "+ category", + "addSubcategory": "+ subcategory", + "newCategoryPrompt": "Name of the new category:", + "newSubcategoryPrompt": "Name of the new subcategory:", + "categoryAddedToast": "Category added.", + "subcategoryAddedToast": "Subcategory added." }, "settings": { "title": "設定", diff --git a/public/locales/pt.json b/public/locales/pt.json index 8f56de8..5c86ccf 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -467,7 +467,7 @@ "catRent": "Aluguel", "catInsurance": "Seguro", "catMobility": "Transporte", - "catLeisure": "Lazer", + "catLeisure": "Lazer e Entretenimento", "catClothing": "Roupas", "catHealth": "Saúde", "catEducation": "Educação", @@ -477,7 +477,54 @@ "catTransferGiftIncome": "Transferências e Presentes", "catGovernmentBenefits": "Benefícios Sociais", "catOtherIncome": "Outras Rendas", - "loadingIndicator": "Carregando…" + "loadingIndicator": "Carregando…", + "subcategoryLabel": "Subcategoria", + "catHousing": "Habitação / Casa", + "catTransport": "Transporte", + "catPersonalHealth": "Cuidados Pessoais / Saúde", + "catShoppingClothing": "Compras e Vestuário", + "catFinancialOther": "Serviços Financeiros e Outros", + "subcatRentMortgage": "Aluguel / Prestação", + "subcatCondominium": "Condomínio", + "subcatUtilities": "Luz / Água / Gás", + "subcatInternetTvPhone": "Internet / TV / Telefone", + "subcatRenovationMaintenance": "Reforma / Manutenção", + "subcatCleaning": "Limpeza", + "subcatGroceries": "Supermercado", + "subcatRestaurantsBars": "Restaurante / Bares", + "subcatSnacksFastFood": "Lanches / Fast Food", + "subcatBakery": "Padaria", + "subcatFuel": "Combustível", + "subcatParkingTolls": "Estacionamento / Pedágio", + "subcatPublicTransport": "Transporte Público", + "subcatAppsTaxi": "Aplicativos / Táxi", + "subcatMaintenanceInsurance": "Manutenção / Seguro", + "subcatPharmacy": "Farmácia", + "subcatHealthInsurance": "Plano de Saúde", + "subcatGymSports": "Academia / Esportes", + "subcatBeautyCosmetics": "Beleza / Cosméticos", + "subcatTravel": "Viagens", + "subcatStreaming": "Streaming", + "subcatEvents": "Eventos", + "subcatHobbies": "Hobbies", + "subcatClothesShoes": "Roupas / Calçados", + "subcatElectronics": "Eletrônicos", + "subcatGifts": "Presentes", + "subcatCoursesCollege": "Cursos / Faculdade", + "subcatSchoolSupplies": "Material Escolar", + "subcatLanguages": "Idiomas", + "subcatLoansInterest": "Empréstimos / Juros", + "subcatBankFees": "Tarifas Bancárias", + "subcatInsuranceOther": "Seguros", + "subcatInvestments": "Investimentos", + "subcatTaxes": "Impostos", + "metaLoadError": "Categorias do orçamento não puderam ser carregadas.", + "addCategory": "+ categoria", + "addSubcategory": "+ subcategoria", + "newCategoryPrompt": "Nome da nova categoria:", + "newSubcategoryPrompt": "Nome da nova subcategoria:", + "categoryAddedToast": "Categoria adicionada.", + "subcategoryAddedToast": "Subcategoria adicionada." }, "settings": { "title": "Configurações", diff --git a/public/locales/ru.json b/public/locales/ru.json index 6363454..dca2ff4 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -463,21 +463,68 @@ "trendNeutral": "— как в {{month}}", "validAmountRequired": "Введите корректную сумму", "dateRequired": "Дата обязательна", - "catFood": "Продукты", + "catFood": "Food", "catRent": "Аренда", "catInsurance": "Страховка", "catMobility": "Транспорт", - "catLeisure": "Досуг", + "catLeisure": "Leisure and Entertainment", "catClothing": "Одежда", "catHealth": "Здоровье", - "catEducation": "Образование", + "catEducation": "Education", "catMisc": "Разное", "catEarnedIncome": "Трудовой доход", "catInvestmentIncome": "Инвестиционный доход", "catTransferGiftIncome": "Переводы и подарки", "catGovernmentBenefits": "Социальные пособия", "catOtherIncome": "Прочие доходы", - "loadingIndicator": "Загрузка…" + "loadingIndicator": "Загрузка…", + "subcategoryLabel": "Subcategory", + "catHousing": "Housing / Home", + "catTransport": "Transport", + "catPersonalHealth": "Personal Care / Health", + "catShoppingClothing": "Shopping and Clothing", + "catFinancialOther": "Financial Services and Other", + "subcatRentMortgage": "Rent / Mortgage", + "subcatCondominium": "Condominium fees", + "subcatUtilities": "Electricity / Water / Gas", + "subcatInternetTvPhone": "Internet / TV / Phone", + "subcatRenovationMaintenance": "Renovation / Maintenance", + "subcatCleaning": "Cleaning", + "subcatGroceries": "Groceries", + "subcatRestaurantsBars": "Restaurants / Bars", + "subcatSnacksFastFood": "Snacks / Fast Food", + "subcatBakery": "Bakery", + "subcatFuel": "Fuel", + "subcatParkingTolls": "Parking / Tolls", + "subcatPublicTransport": "Public transport", + "subcatAppsTaxi": "Apps / Taxi", + "subcatMaintenanceInsurance": "Maintenance / Insurance", + "subcatPharmacy": "Pharmacy", + "subcatHealthInsurance": "Health insurance", + "subcatGymSports": "Gym / Sports", + "subcatBeautyCosmetics": "Beauty / Cosmetics", + "subcatTravel": "Travel", + "subcatStreaming": "Streaming", + "subcatEvents": "Events", + "subcatHobbies": "Hobbies", + "subcatClothesShoes": "Clothes / Shoes", + "subcatElectronics": "Electronics", + "subcatGifts": "Gifts", + "subcatCoursesCollege": "Courses / College", + "subcatSchoolSupplies": "School supplies", + "subcatLanguages": "Languages", + "subcatLoansInterest": "Loans / Interest", + "subcatBankFees": "Bank fees", + "subcatInsuranceOther": "Insurance", + "subcatInvestments": "Investments", + "subcatTaxes": "Taxes", + "metaLoadError": "Budget categories could not be loaded.", + "addCategory": "+ category", + "addSubcategory": "+ subcategory", + "newCategoryPrompt": "Name of the new category:", + "newSubcategoryPrompt": "Name of the new subcategory:", + "categoryAddedToast": "Category added.", + "subcategoryAddedToast": "Subcategory added." }, "settings": { "title": "Настройки", diff --git a/public/locales/sv.json b/public/locales/sv.json index a0949af..5d36878 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -463,21 +463,68 @@ "trendNeutral": "- samma som {{month}}", "validAmountRequired": "Ange ett giltigt belopp", "dateRequired": "Datum krävs", - "catFood": "Specerier", + "catFood": "Food", "catRent": "Hyra", "catInsurance": "Försäkring", "catMobility": "Transport", - "catLeisure": "Fritid", + "catLeisure": "Leisure and Entertainment", "catClothing": "Kläder", "catHealth": "Hälsa", - "catEducation": "Utbildning", + "catEducation": "Education", "catMisc": "Diverse", "catEarnedIncome": "Arbetsinkomst", "catInvestmentIncome": "Investeringsinkomst", "catTransferGiftIncome": "Överföringar och gåvor", "catGovernmentBenefits": "Socialförmåner", "catOtherIncome": "Övrig inkomst", - "loadingIndicator": "Laddar…" + "loadingIndicator": "Laddar…", + "subcategoryLabel": "Subcategory", + "catHousing": "Housing / Home", + "catTransport": "Transport", + "catPersonalHealth": "Personal Care / Health", + "catShoppingClothing": "Shopping and Clothing", + "catFinancialOther": "Financial Services and Other", + "subcatRentMortgage": "Rent / Mortgage", + "subcatCondominium": "Condominium fees", + "subcatUtilities": "Electricity / Water / Gas", + "subcatInternetTvPhone": "Internet / TV / Phone", + "subcatRenovationMaintenance": "Renovation / Maintenance", + "subcatCleaning": "Cleaning", + "subcatGroceries": "Groceries", + "subcatRestaurantsBars": "Restaurants / Bars", + "subcatSnacksFastFood": "Snacks / Fast Food", + "subcatBakery": "Bakery", + "subcatFuel": "Fuel", + "subcatParkingTolls": "Parking / Tolls", + "subcatPublicTransport": "Public transport", + "subcatAppsTaxi": "Apps / Taxi", + "subcatMaintenanceInsurance": "Maintenance / Insurance", + "subcatPharmacy": "Pharmacy", + "subcatHealthInsurance": "Health insurance", + "subcatGymSports": "Gym / Sports", + "subcatBeautyCosmetics": "Beauty / Cosmetics", + "subcatTravel": "Travel", + "subcatStreaming": "Streaming", + "subcatEvents": "Events", + "subcatHobbies": "Hobbies", + "subcatClothesShoes": "Clothes / Shoes", + "subcatElectronics": "Electronics", + "subcatGifts": "Gifts", + "subcatCoursesCollege": "Courses / College", + "subcatSchoolSupplies": "School supplies", + "subcatLanguages": "Languages", + "subcatLoansInterest": "Loans / Interest", + "subcatBankFees": "Bank fees", + "subcatInsuranceOther": "Insurance", + "subcatInvestments": "Investments", + "subcatTaxes": "Taxes", + "metaLoadError": "Budget categories could not be loaded.", + "addCategory": "+ category", + "addSubcategory": "+ subcategory", + "newCategoryPrompt": "Name of the new category:", + "newSubcategoryPrompt": "Name of the new subcategory:", + "categoryAddedToast": "Category added.", + "subcategoryAddedToast": "Subcategory added." }, "settings": { "title": "Inställningar", diff --git a/public/locales/tr.json b/public/locales/tr.json index c25ee13..bd0d42d 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -463,21 +463,68 @@ "trendNeutral": "- {{month}} ile aynı", "validAmountRequired": "Lütfen geçerli bir tutar girin", "dateRequired": "Tarih zorunludur", - "catFood": "Market", + "catFood": "Food", "catRent": "Kira", "catInsurance": "Sigorta", "catMobility": "Ulaşım", - "catLeisure": "Eğlence", + "catLeisure": "Leisure and Entertainment", "catClothing": "Giyim", "catHealth": "Sağlık", - "catEducation": "Eğitim", + "catEducation": "Education", "catMisc": "Diğer", "catEarnedIncome": "Kazanç Geliri", "catInvestmentIncome": "Yatırım Geliri", "catTransferGiftIncome": "Transferler ve Hediyeler", "catGovernmentBenefits": "Sosyal Yardımlar", "catOtherIncome": "Diğer Gelir", - "loadingIndicator": "Yükleniyor…" + "loadingIndicator": "Yükleniyor…", + "subcategoryLabel": "Subcategory", + "catHousing": "Housing / Home", + "catTransport": "Transport", + "catPersonalHealth": "Personal Care / Health", + "catShoppingClothing": "Shopping and Clothing", + "catFinancialOther": "Financial Services and Other", + "subcatRentMortgage": "Rent / Mortgage", + "subcatCondominium": "Condominium fees", + "subcatUtilities": "Electricity / Water / Gas", + "subcatInternetTvPhone": "Internet / TV / Phone", + "subcatRenovationMaintenance": "Renovation / Maintenance", + "subcatCleaning": "Cleaning", + "subcatGroceries": "Groceries", + "subcatRestaurantsBars": "Restaurants / Bars", + "subcatSnacksFastFood": "Snacks / Fast Food", + "subcatBakery": "Bakery", + "subcatFuel": "Fuel", + "subcatParkingTolls": "Parking / Tolls", + "subcatPublicTransport": "Public transport", + "subcatAppsTaxi": "Apps / Taxi", + "subcatMaintenanceInsurance": "Maintenance / Insurance", + "subcatPharmacy": "Pharmacy", + "subcatHealthInsurance": "Health insurance", + "subcatGymSports": "Gym / Sports", + "subcatBeautyCosmetics": "Beauty / Cosmetics", + "subcatTravel": "Travel", + "subcatStreaming": "Streaming", + "subcatEvents": "Events", + "subcatHobbies": "Hobbies", + "subcatClothesShoes": "Clothes / Shoes", + "subcatElectronics": "Electronics", + "subcatGifts": "Gifts", + "subcatCoursesCollege": "Courses / College", + "subcatSchoolSupplies": "School supplies", + "subcatLanguages": "Languages", + "subcatLoansInterest": "Loans / Interest", + "subcatBankFees": "Bank fees", + "subcatInsuranceOther": "Insurance", + "subcatInvestments": "Investments", + "subcatTaxes": "Taxes", + "metaLoadError": "Budget categories could not be loaded.", + "addCategory": "+ category", + "addSubcategory": "+ subcategory", + "newCategoryPrompt": "Name of the new category:", + "newSubcategoryPrompt": "Name of the new subcategory:", + "categoryAddedToast": "Category added.", + "subcategoryAddedToast": "Subcategory added." }, "settings": { "title": "Ayarlar", diff --git a/public/locales/uk.json b/public/locales/uk.json index 1dfd859..1cff39c 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -463,21 +463,68 @@ "trendNeutral": "— так само, як у {{month}}", "validAmountRequired": "Будь ласка, введіть коректну суму", "dateRequired": "Дата є обов'язковою", - "catFood": "Продукти", + "catFood": "Food", "catRent": "Оренда", "catInsurance": "Страхування", "catMobility": "Транспорт", - "catLeisure": "Дозвілля", + "catLeisure": "Leisure and Entertainment", "catClothing": "Одяг", "catHealth": "Здоров'я", - "catEducation": "Освіта", + "catEducation": "Education", "catMisc": "Різне", "catEarnedIncome": "Трудовий дохід", "catInvestmentIncome": "Інвестиційний дохід", "catTransferGiftIncome": "Переводи та подарунки", "catGovernmentBenefits": "Соціальні виплати", "catOtherIncome": "Інші доходи", - "loadingIndicator": "Завантаження…" + "loadingIndicator": "Завантаження…", + "subcategoryLabel": "Subcategory", + "catHousing": "Housing / Home", + "catTransport": "Transport", + "catPersonalHealth": "Personal Care / Health", + "catShoppingClothing": "Shopping and Clothing", + "catFinancialOther": "Financial Services and Other", + "subcatRentMortgage": "Rent / Mortgage", + "subcatCondominium": "Condominium fees", + "subcatUtilities": "Electricity / Water / Gas", + "subcatInternetTvPhone": "Internet / TV / Phone", + "subcatRenovationMaintenance": "Renovation / Maintenance", + "subcatCleaning": "Cleaning", + "subcatGroceries": "Groceries", + "subcatRestaurantsBars": "Restaurants / Bars", + "subcatSnacksFastFood": "Snacks / Fast Food", + "subcatBakery": "Bakery", + "subcatFuel": "Fuel", + "subcatParkingTolls": "Parking / Tolls", + "subcatPublicTransport": "Public transport", + "subcatAppsTaxi": "Apps / Taxi", + "subcatMaintenanceInsurance": "Maintenance / Insurance", + "subcatPharmacy": "Pharmacy", + "subcatHealthInsurance": "Health insurance", + "subcatGymSports": "Gym / Sports", + "subcatBeautyCosmetics": "Beauty / Cosmetics", + "subcatTravel": "Travel", + "subcatStreaming": "Streaming", + "subcatEvents": "Events", + "subcatHobbies": "Hobbies", + "subcatClothesShoes": "Clothes / Shoes", + "subcatElectronics": "Electronics", + "subcatGifts": "Gifts", + "subcatCoursesCollege": "Courses / College", + "subcatSchoolSupplies": "School supplies", + "subcatLanguages": "Languages", + "subcatLoansInterest": "Loans / Interest", + "subcatBankFees": "Bank fees", + "subcatInsuranceOther": "Insurance", + "subcatInvestments": "Investments", + "subcatTaxes": "Taxes", + "metaLoadError": "Budget categories could not be loaded.", + "addCategory": "+ category", + "addSubcategory": "+ subcategory", + "newCategoryPrompt": "Name of the new category:", + "newSubcategoryPrompt": "Name of the new subcategory:", + "categoryAddedToast": "Category added.", + "subcategoryAddedToast": "Subcategory added." }, "settings": { "title": "Налаштування", diff --git a/public/locales/zh.json b/public/locales/zh.json index 16a1f7d..539bf8b 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -463,21 +463,68 @@ "trendNeutral": "- 与 {{month}} 相同", "validAmountRequired": "请输入有效金额", "dateRequired": "日期为必填项", - "catFood": "食品", + "catFood": "Food", "catRent": "租金", "catInsurance": "保险", "catMobility": "出行", - "catLeisure": "休闲", + "catLeisure": "Leisure and Entertainment", "catClothing": "服装", "catHealth": "健康", - "catEducation": "教育", + "catEducation": "Education", "catMisc": "其他", "catEarnedIncome": "劳动收入", "catInvestmentIncome": "投资收入", "catTransferGiftIncome": "转账和礼物", "catGovernmentBenefits": "社会福利", "catOtherIncome": "其他收入", - "loadingIndicator": "加载中…" + "loadingIndicator": "加载中…", + "subcategoryLabel": "Subcategory", + "catHousing": "Housing / Home", + "catTransport": "Transport", + "catPersonalHealth": "Personal Care / Health", + "catShoppingClothing": "Shopping and Clothing", + "catFinancialOther": "Financial Services and Other", + "subcatRentMortgage": "Rent / Mortgage", + "subcatCondominium": "Condominium fees", + "subcatUtilities": "Electricity / Water / Gas", + "subcatInternetTvPhone": "Internet / TV / Phone", + "subcatRenovationMaintenance": "Renovation / Maintenance", + "subcatCleaning": "Cleaning", + "subcatGroceries": "Groceries", + "subcatRestaurantsBars": "Restaurants / Bars", + "subcatSnacksFastFood": "Snacks / Fast Food", + "subcatBakery": "Bakery", + "subcatFuel": "Fuel", + "subcatParkingTolls": "Parking / Tolls", + "subcatPublicTransport": "Public transport", + "subcatAppsTaxi": "Apps / Taxi", + "subcatMaintenanceInsurance": "Maintenance / Insurance", + "subcatPharmacy": "Pharmacy", + "subcatHealthInsurance": "Health insurance", + "subcatGymSports": "Gym / Sports", + "subcatBeautyCosmetics": "Beauty / Cosmetics", + "subcatTravel": "Travel", + "subcatStreaming": "Streaming", + "subcatEvents": "Events", + "subcatHobbies": "Hobbies", + "subcatClothesShoes": "Clothes / Shoes", + "subcatElectronics": "Electronics", + "subcatGifts": "Gifts", + "subcatCoursesCollege": "Courses / College", + "subcatSchoolSupplies": "School supplies", + "subcatLanguages": "Languages", + "subcatLoansInterest": "Loans / Interest", + "subcatBankFees": "Bank fees", + "subcatInsuranceOther": "Insurance", + "subcatInvestments": "Investments", + "subcatTaxes": "Taxes", + "metaLoadError": "Budget categories could not be loaded.", + "addCategory": "+ category", + "addSubcategory": "+ subcategory", + "newCategoryPrompt": "Name of the new category:", + "newSubcategoryPrompt": "Name of the new subcategory:", + "categoryAddedToast": "Category added.", + "subcategoryAddedToast": "Subcategory added." }, "settings": { "title": "设置", diff --git a/public/pages/budget.js b/public/pages/budget.js index 5eb29d8..df34311 100644 --- a/public/pages/budget.js +++ b/public/pages/budget.js @@ -15,29 +15,16 @@ import { esc } from '/utils/html.js'; // Konstanten // -------------------------------------------------------- -const EXPENSE_CATEGORIES = [ - 'Lebensmittel', 'Miete', 'Versicherung', 'Mobilität', - 'Freizeit', 'Kleidung', 'Gesundheit', 'Bildung', 'Sonstiges', -]; - -const INCOME_CATEGORIES = [ - 'Erwerbseinkommen', 'Kapitalerträge', 'Geschenke & Transfers', - 'Sozialleistungen', 'Sonstiges Einkommen', -]; - -const CATEGORIES = [...EXPENSE_CATEGORIES, ...INCOME_CATEGORIES]; - -const CATEGORY_LABELS = () => ({ +const CATEGORY_I18N = () => ({ // Expense categories - 'Lebensmittel': t('budget.catFood'), - 'Miete': t('budget.catRent'), - 'Versicherung': t('budget.catInsurance'), - 'Mobilität': t('budget.catMobility'), - 'Freizeit': t('budget.catLeisure'), - 'Kleidung': t('budget.catClothing'), - 'Gesundheit': t('budget.catHealth'), - 'Bildung': t('budget.catEducation'), - 'Sonstiges': t('budget.catMisc'), + housing: t('budget.catHousing'), + food: t('budget.catFood'), + transport: t('budget.catTransport'), + personal_health: t('budget.catPersonalHealth'), + leisure: t('budget.catLeisure'), + shopping_clothing: t('budget.catShoppingClothing'), + education: t('budget.catEducation'), + financial_other: t('budget.catFinancialOther'), // Income categories 'Erwerbseinkommen': t('budget.catEarnedIncome'), 'Kapitalerträge': t('budget.catInvestmentIncome'), @@ -46,6 +33,82 @@ const CATEGORY_LABELS = () => ({ 'Sonstiges Einkommen': t('budget.catOtherIncome'), }); +const SUBCATEGORY_I18N = () => ({ + rent_mortgage: t('budget.subcatRentMortgage'), + condominium: t('budget.subcatCondominium'), + utilities: t('budget.subcatUtilities'), + internet_tv_phone: t('budget.subcatInternetTvPhone'), + renovation_maintenance: t('budget.subcatRenovationMaintenance'), + cleaning: t('budget.subcatCleaning'), + groceries: t('budget.subcatGroceries'), + restaurants_bars: t('budget.subcatRestaurantsBars'), + snacks_fast_food: t('budget.subcatSnacksFastFood'), + bakery: t('budget.subcatBakery'), + fuel: t('budget.subcatFuel'), + parking_tolls: t('budget.subcatParkingTolls'), + public_transport: t('budget.subcatPublicTransport'), + apps_taxi: t('budget.subcatAppsTaxi'), + maintenance_insurance: t('budget.subcatMaintenanceInsurance'), + pharmacy: t('budget.subcatPharmacy'), + health_insurance: t('budget.subcatHealthInsurance'), + gym_sports: t('budget.subcatGymSports'), + beauty_cosmetics: t('budget.subcatBeautyCosmetics'), + travel: t('budget.subcatTravel'), + streaming: t('budget.subcatStreaming'), + events: t('budget.subcatEvents'), + hobbies: t('budget.subcatHobbies'), + clothes_shoes: t('budget.subcatClothesShoes'), + electronics: t('budget.subcatElectronics'), + gifts: t('budget.subcatGifts'), + courses_college: t('budget.subcatCoursesCollege'), + school_supplies: t('budget.subcatSchoolSupplies'), + languages: t('budget.subcatLanguages'), + loans_interest: t('budget.subcatLoansInterest'), + bank_fees: t('budget.subcatBankFees'), + insurance_other: t('budget.subcatInsuranceOther'), + investments: t('budget.subcatInvestments'), + taxes: t('budget.subcatTaxes'), +}); + +function categoryLabel(category) { + const item = typeof category === 'object' + ? category + : [...expenseCategories(), ...incomeCategories()].find((c) => c.key === category); + const key = item?.key ?? category; + const name = item?.name ?? category; + return CATEGORY_I18N()[key] ?? name; +} + +function subcategoryLabel(subcategory) { + const item = typeof subcategory === 'object' + ? subcategory + : Object.values(state.meta.expenseSubcategories ?? {}).flat().find((s) => s.key === subcategory); + const key = item?.key ?? subcategory; + const name = item?.name ?? subcategory; + return SUBCATEGORY_I18N()[key] ?? name; +} + +function expenseCategories() { + return state.meta.expenseCategories ?? []; +} + +function incomeCategories() { + return state.meta.incomeCategories ?? []; +} + +function getSubcategories(category) { + return state.meta.expenseSubcategories?.[category] || []; +} + +function defaultSubcategory(category) { + return getSubcategories(category)[0]?.key || ''; +} + +function defaultCategory(type) { + const cats = type === 'income' ? incomeCategories() : expenseCategories(); + return cats[0]?.key || ''; +} + function getMonthName(monthIndex) { // monthIndex: 0-based (0=Januar, 11=Dezember) const date = new Date(2000, monthIndex, 1); @@ -62,6 +125,7 @@ let state = { summary: null, prevSummary: null, // Vormonat für Monatsvergleich currency: 'EUR', + meta: { expenseCategories: [], incomeCategories: [], expenseSubcategories: {} }, }; let _container = null; @@ -110,6 +174,21 @@ async function loadMonth(month) { } } +async function loadBudgetMeta() { + try { + const res = await api.get('/budget/meta'); + state.meta = { + expenseCategories: res.data?.expenseCategories ?? [], + incomeCategories: res.data?.incomeCategories ?? [], + expenseSubcategories: res.data?.expenseSubcategories ?? {}, + }; + } catch (err) { + console.error('[Budget] meta Fehler:', err); + state.meta = { expenseCategories: [], incomeCategories: [], expenseSubcategories: {} }; + window.oikos?.showToast(t('budget.metaLoadError'), 'danger'); + } +} + // -------------------------------------------------------- // Entry Point // -------------------------------------------------------- @@ -120,7 +199,10 @@ export async function render(container, { user }) { state.month = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`; try { - const prefsRes = await api.get('/preferences'); + const [prefsRes] = await Promise.all([ + api.get('/preferences'), + loadBudgetMeta(), + ]); state.currency = prefsRes.data?.currency ?? 'EUR'; } catch (_) { /* Fallback auf EUR */ } @@ -274,7 +356,7 @@ function renderCategoryBars(byCategory) { return `
-
${esc(CATEGORY_LABELS()[c.category] ?? c.category)}
+
${esc(categoryLabel(c.category))}
@@ -305,13 +387,16 @@ function renderEntries() { const sign = isIncome ? '+' : ''; const date = formatEntryDate(e.date); const recurTag = e.is_recurring ? ' 🔁' : (e.recurrence_parent_id ? ' ↩' : ''); + const categoryMeta = isIncome || !e.subcategory + ? categoryLabel(e.category) + : `${categoryLabel(e.category)} · ${subcategoryLabel(e.subcategory)}`; return `
${esc(e.title)}
- +
${sign}${formatAmount(e.amount)}
- +
+ + +
+
+
+ + +
+ +
+
{ - const catLabels = CATEGORY_LABELS(); - const cats = currentType === 'income' ? INCOME_CATEGORIES : EXPENSE_CATEGORIES; + const updateCategoryOptions = (preferredCategory = '') => { + const cats = currentType === 'income' ? incomeCategories() : expenseCategories(); const catSelect = panel.querySelector('#bm-category'); - const currentValue = catSelect.value; + const currentValue = preferredCategory || catSelect.value; const options = cats.map((c) => { const opt = document.createElement('option'); - opt.value = c; - opt.textContent = catLabels[c] || c; - opt.selected = currentValue === c; + opt.value = c.key; + opt.textContent = categoryLabel(c); + opt.selected = currentValue === c.key; return opt; }); catSelect.replaceChildren(...options); + if (!cats.some((c) => c.key === catSelect.value)) catSelect.value = cats[0]?.key || ''; + updateSubcategoryOptions(); + }; + + const updateSubcategoryOptions = (preferredSubcategory = '') => { + const catSelect = panel.querySelector('#bm-category'); + const subcatGroup = panel.querySelector('#bm-subcategory-group'); + const subcatSelect = panel.querySelector('#bm-subcategory'); + const subcategories = currentType === 'expense' ? getSubcategories(catSelect.value) : []; + const currentValue = preferredSubcategory || subcatSelect.value; + + subcatGroup.hidden = currentType !== 'expense'; + subcatSelect.replaceChildren(...subcategories.map((s) => { + const opt = document.createElement('option'); + opt.value = s.key; + opt.textContent = subcategoryLabel(s); + opt.selected = currentValue === s.key; + return opt; + })); + if (subcategories.length && !subcategories.some((s) => s.key === subcatSelect.value)) { + subcatSelect.value = subcategories[0].key; + } + }; + + const addCategory = async () => { + const name = window.prompt(t('budget.newCategoryPrompt')); + if (!name?.trim()) return; + try { + const res = await api.post('/budget/categories', { name: name.trim(), type: currentType }); + await loadBudgetMeta(); + updateCategoryOptions(res.data.key); + window.oikos?.showToast(t('budget.categoryAddedToast'), 'success'); + } catch (err) { + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); + } + }; + + const addSubcategory = async () => { + if (currentType !== 'expense') return; + const category = panel.querySelector('#bm-category').value; + if (!category) return; + const name = window.prompt(t('budget.newSubcategoryPrompt')); + if (!name?.trim()) return; + try { + const res = await api.post(`/budget/categories/${encodeURIComponent(category)}/subcategories`, { name: name.trim() }); + await loadBudgetMeta(); + updateSubcategoryOptions(res.data.key); + window.oikos?.showToast(t('budget.subcategoryAddedToast'), 'success'); + } catch (err) { + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); + } }; panel.querySelector('#type-expense').addEventListener('click', () => { @@ -450,6 +600,9 @@ function openBudgetModal({ mode, entry = null }) { panel.querySelector('#type-expense').classList.remove('amount-type-btn--active'); updateCategoryOptions(); }); + panel.querySelector('#bm-category').addEventListener('change', () => updateSubcategoryOptions()); + panel.querySelector('#bm-add-category').addEventListener('click', addCategory); + panel.querySelector('#bm-add-subcategory').addEventListener('click', addSubcategory); panel.querySelector('#bm-cancel').addEventListener('click', closeModal); @@ -463,6 +616,7 @@ function openBudgetModal({ mode, entry = null }) { const title = panel.querySelector('#bm-title').value.trim(); const absVal = parseFloat(panel.querySelector('#bm-amount').value); const category = panel.querySelector('#bm-category').value; + const subcategory = currentType === 'expense' ? panel.querySelector('#bm-subcategory').value : ''; const date = panel.querySelector('#bm-date').value; const recurring = panel.querySelector('#bm-recurring').checked ? 1 : 0; @@ -476,7 +630,7 @@ function openBudgetModal({ mode, entry = null }) { saveBtn.textContent = '…'; try { - const body = { title, amount, category, date, is_recurring: recurring }; + const body = { title, amount, category, subcategory, date, is_recurring: recurring }; if (mode === 'create') { const res = await api.post('/budget', body); state.entries.unshift(res.data); @@ -523,4 +677,3 @@ async function deleteEntry(id) { // -------------------------------------------------------- // Hilfsfunktion // -------------------------------------------------------- - diff --git a/public/styles/budget.css b/public/styles/budget.css index c6b61dd..d9c0e3e 100644 --- a/public/styles/budget.css +++ b/public/styles/budget.css @@ -331,3 +331,20 @@ color: var(--color-text-on-accent); } +.budget-field-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + margin-bottom: var(--space-1); +} + +.budget-field-header .form-label { + margin-bottom: 0; +} + +.budget-inline-add { + min-height: auto; + padding: 2px var(--space-2); + font-size: var(--text-xs); +} diff --git a/public/sw.js b/public/sw.js index 5772d64..96ab73d 100644 --- a/public/sw.js +++ b/public/sw.js @@ -13,9 +13,9 @@ * → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit) */ -const SHELL_CACHE = 'oikos-shell-v50'; -const PAGES_CACHE = 'oikos-pages-v45'; -const ASSETS_CACHE = 'oikos-assets-v45'; +const SHELL_CACHE = 'oikos-shell-v52'; +const PAGES_CACHE = 'oikos-pages-v47'; +const ASSETS_CACHE = 'oikos-assets-v47'; const BYPASS_CACHE = 'oikos-bypass-flag'; const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE]; diff --git a/server/db-schema-test.js b/server/db-schema-test.js index 03f9024..e34e67c 100644 --- a/server/db-schema-test.js +++ b/server/db-schema-test.js @@ -121,6 +121,7 @@ const MIGRATIONS_SQL = { title TEXT NOT NULL, amount REAL NOT NULL, category TEXT NOT NULL DEFAULT 'Sonstiges', + subcategory TEXT NOT NULL DEFAULT '', date TEXT NOT NULL, is_recurring INTEGER NOT NULL DEFAULT 0, recurrence_rule TEXT, @@ -128,6 +129,21 @@ const MIGRATIONS_SQL = { created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ); + CREATE TABLE IF NOT EXISTS budget_categories ( + key TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('expense', 'income')), + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + CREATE TABLE IF NOT EXISTS budget_subcategories ( + key TEXT PRIMARY KEY, + category_key TEXT NOT NULL REFERENCES budget_categories(key) ON DELETE CASCADE, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + UNIQUE(category_key, name) + ); CREATE TRIGGER IF NOT EXISTS trg_users_updated_at AFTER UPDATE ON users FOR EACH ROW BEGIN UPDATE users SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END; diff --git a/server/db.js b/server/db.js index 5906850..2b50c28 100644 --- a/server/db.js +++ b/server/db.js @@ -544,6 +544,133 @@ const MIGRATIONS = [ CREATE INDEX IF NOT EXISTS idx_cal_events_ref ON calendar_events(calendar_ref_id); `, }, + { + version: 15, + description: 'Budget-Ausgabenkategorien als stabile Schlüssel mit Unterkategorien', + up: ` + ALTER TABLE budget_entries ADD COLUMN subcategory TEXT NOT NULL DEFAULT ''; + + UPDATE budget_entries + SET category = CASE category + WHEN 'Lebensmittel' THEN 'food' + WHEN 'Miete' THEN 'housing' + WHEN 'Versicherung' THEN 'financial_other' + WHEN 'Mobilität' THEN 'transport' + WHEN 'Freizeit' THEN 'leisure' + WHEN 'Kleidung' THEN 'shopping_clothing' + WHEN 'Gesundheit' THEN 'personal_health' + WHEN 'Bildung' THEN 'education' + WHEN 'Sonstiges' THEN 'financial_other' + ELSE category + END + WHERE amount < 0; + + UPDATE budget_entries + SET subcategory = CASE category + WHEN 'housing' THEN 'rent_mortgage' + WHEN 'food' THEN 'groceries' + WHEN 'transport' THEN 'fuel' + WHEN 'personal_health' THEN 'pharmacy' + WHEN 'leisure' THEN 'events' + WHEN 'shopping_clothing' THEN 'clothes_shoes' + WHEN 'education' THEN 'courses_college' + WHEN 'financial_other' THEN 'insurance_other' + ELSE '' + END + WHERE amount < 0 AND subcategory = ''; + + UPDATE budget_entries + SET category = 'Sonstiges Einkommen' + WHERE amount > 0 AND category = 'Sonstiges'; + `, + }, + { + version: 16, + description: 'Budget-Kategorien und Unterkategorien in eigene Tabellen auslagern', + up: ` + CREATE TABLE IF NOT EXISTS budget_categories ( + key TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('expense', 'income')), + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + CREATE TABLE IF NOT EXISTS budget_subcategories ( + key TEXT PRIMARY KEY, + category_key TEXT NOT NULL REFERENCES budget_categories(key) ON DELETE CASCADE, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + UNIQUE(category_key, name) + ); + + INSERT OR IGNORE INTO budget_categories (key, name, type, sort_order) VALUES + ('housing', 'Habitação / Casa', 'expense', 0), + ('food', 'Alimentação', 'expense', 1), + ('transport', 'Transporte', 'expense', 2), + ('personal_health', 'Cuidados Pessoais / Saúde', 'expense', 3), + ('leisure', 'Lazer e Entretenimento', 'expense', 4), + ('shopping_clothing', 'Compras e Vestuário', 'expense', 5), + ('education', 'Educação', 'expense', 6), + ('financial_other', 'Serviços Financeiros e Outros', 'expense', 7), + ('Erwerbseinkommen', 'Erwerbseinkommen', 'income', 0), + ('Kapitalerträge', 'Kapitalerträge', 'income', 1), + ('Geschenke & Transfers', 'Geschenke & Transfers', 'income', 2), + ('Sozialleistungen', 'Sozialleistungen', 'income', 3), + ('Sonstiges Einkommen', 'Sonstiges Einkommen', 'income', 4); + + INSERT OR IGNORE INTO budget_subcategories (key, category_key, name, sort_order) VALUES + ('rent_mortgage', 'housing', 'Aluguel / Prestação', 0), + ('condominium', 'housing', 'Condomínio', 1), + ('utilities', 'housing', 'Luz / Água / Gás', 2), + ('internet_tv_phone', 'housing', 'Internet / TV / Telefone', 3), + ('renovation_maintenance', 'housing', 'Reforma / Manutenção', 4), + ('cleaning', 'housing', 'Limpeza', 5), + ('groceries', 'food', 'Supermercado', 0), + ('restaurants_bars', 'food', 'Restaurante / Bares', 1), + ('snacks_fast_food', 'food', 'Lanches / Fast Food', 2), + ('bakery', 'food', 'Padaria', 3), + ('fuel', 'transport', 'Combustível', 0), + ('parking_tolls', 'transport', 'Estacionamento / Pedágio', 1), + ('public_transport', 'transport', 'Transporte Público', 2), + ('apps_taxi', 'transport', 'Aplicativos / Táxi', 3), + ('maintenance_insurance', 'transport', 'Manutenção / Seguro', 4), + ('pharmacy', 'personal_health', 'Farmácia', 0), + ('health_insurance', 'personal_health', 'Plano de Saúde', 1), + ('gym_sports', 'personal_health', 'Academia / Esportes', 2), + ('beauty_cosmetics', 'personal_health', 'Beleza / Cosméticos', 3), + ('travel', 'leisure', 'Viagens', 0), + ('streaming', 'leisure', 'Streaming', 1), + ('events', 'leisure', 'Eventos', 2), + ('hobbies', 'leisure', 'Hobbies', 3), + ('clothes_shoes', 'shopping_clothing', 'Roupas / Calçados', 0), + ('electronics', 'shopping_clothing', 'Eletrônicos', 1), + ('gifts', 'shopping_clothing', 'Presentes', 2), + ('courses_college', 'education', 'Cursos / Faculdade', 0), + ('school_supplies', 'education', 'Material Escolar', 1), + ('languages', 'education', 'Idiomas', 2), + ('loans_interest', 'financial_other', 'Empréstimos / Juros', 0), + ('bank_fees', 'financial_other', 'Tarifas Bancárias', 1), + ('insurance_other', 'financial_other', 'Seguros', 2), + ('investments', 'financial_other', 'Investimentos', 3), + ('taxes', 'financial_other', 'Impostos', 4); + + INSERT OR IGNORE INTO budget_categories (key, name, type, sort_order) + SELECT category, category, CASE WHEN amount < 0 THEN 'expense' ELSE 'income' END, 1000 + FROM budget_entries + WHERE category NOT IN (SELECT key FROM budget_categories) + GROUP BY category; + + INSERT OR IGNORE INTO budget_subcategories (key, category_key, name, sort_order) + SELECT subcategory, category, subcategory, 1000 + FROM budget_entries + WHERE subcategory != '' + AND subcategory NOT IN (SELECT key FROM budget_subcategories) + AND category IN (SELECT key FROM budget_categories WHERE type = 'expense') + GROUP BY category, subcategory; + `, + }, ]; /** diff --git a/server/routes/budget.js b/server/routes/budget.js index c5bfdf4..cc12771 100644 --- a/server/routes/budget.js +++ b/server/routes/budget.js @@ -7,7 +7,7 @@ import { createLogger } from '../logger.js'; import express from 'express'; import * as db from '../db.js'; -import { str, oneOf, date as validateDate, num, rrule, collectErrors, MAX_TITLE, MONTH_RE } from '../middleware/validate.js'; +import { str, oneOf, date as validateDate, num, rrule, collectErrors, MAX_TITLE, MAX_SHORT, MONTH_RE } from '../middleware/validate.js'; const log = createLogger('Budget'); @@ -57,23 +57,87 @@ function generateRecurringInstances(database, month) { database.prepare(` INSERT INTO budget_entries - (title, amount, category, date, is_recurring, recurrence_parent_id, created_by) - VALUES (?, ?, ?, ?, 0, ?, ?) - `).run(orig.title, orig.amount, orig.category, instanceDate, orig.id, orig.created_by); + (title, amount, category, subcategory, date, is_recurring, recurrence_parent_id, created_by) + VALUES (?, ?, ?, ?, ?, 0, ?, ?) + `).run(orig.title, orig.amount, orig.category, orig.subcategory || '', instanceDate, orig.id, orig.created_by); } } -const EXPENSE_CATEGORIES = [ - 'Lebensmittel', 'Miete', 'Versicherung', 'Mobilität', - 'Freizeit', 'Kleidung', 'Gesundheit', 'Bildung', 'Sonstiges', -]; +function slugify(value) { + return String(value || '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 48) || 'category'; +} -const INCOME_CATEGORIES = [ - 'Erwerbseinkommen', 'Kapitalerträge', 'Geschenke & Transfers', - 'Sozialleistungen', 'Sonstiges Einkommen', -]; +function uniqueKey(table, base) { + const normalized = slugify(base); + let key = normalized; + let i = 2; + const exists = db.get().prepare(`SELECT 1 FROM ${table} WHERE key = ?`); + while (exists.get(key)) { + key = `${normalized}_${i}`; + i += 1; + } + return key; +} -const VALID_CATEGORIES = [...EXPENSE_CATEGORIES, ...INCOME_CATEGORIES]; +function loadBudgetMeta() { + const categories = db.get().prepare(` + SELECT key, name, type, sort_order + FROM budget_categories + ORDER BY type DESC, sort_order ASC, name COLLATE NOCASE ASC + `).all(); + const subcategories = db.get().prepare(` + SELECT key, category_key, name, sort_order + FROM budget_subcategories + ORDER BY sort_order ASC, name COLLATE NOCASE ASC + `).all(); + + const expenseCategories = categories.filter((c) => c.type === 'expense'); + const incomeCategories = categories.filter((c) => c.type === 'income'); + const expenseSubcategories = {}; + for (const sub of subcategories) { + if (!expenseSubcategories[sub.category_key]) expenseSubcategories[sub.category_key] = []; + expenseSubcategories[sub.category_key].push(sub); + } + + return { categories, expenseCategories, incomeCategories, expenseSubcategories }; +} + +function validCategoryKeys() { + return db.get().prepare('SELECT key FROM budget_categories').all().map((c) => c.key); +} + +function validExpenseCategoryKeys() { + return db.get().prepare("SELECT key FROM budget_categories WHERE type = 'expense'").all().map((c) => c.key); +} + +function defaultCategory(type) { + const row = db.get().prepare(` + SELECT key FROM budget_categories WHERE type = ? ORDER BY sort_order ASC, name COLLATE NOCASE ASC LIMIT 1 + `).get(type); + return row?.key || (type === 'expense' ? 'financial_other' : 'Sonstiges Einkommen'); +} + +function defaultSubcategory(category) { + const row = db.get().prepare(` + SELECT key FROM budget_subcategories WHERE category_key = ? ORDER BY sort_order ASC, name COLLATE NOCASE ASC LIMIT 1 + `).get(category); + return row?.key || ''; +} + +function validateSubcategory(category, subcategory) { + if (!validExpenseCategoryKeys().includes(category)) return ''; + if (!subcategory) return defaultSubcategory(category); + const row = db.get().prepare(` + SELECT 1 FROM budget_subcategories WHERE category_key = ? AND key = ? + `).get(category, subcategory); + return row ? subcategory : null; +} // -------------------------------------------------------- // Statische Routen vor /:id @@ -155,7 +219,7 @@ router.get('/export', (req, res) => { ORDER BY b.date ASC `).all(from, to); - const header = 'Datum,Titel,Betrag,Kategorie,Wiederkehrend,Erstellt von\n'; + const header = 'Datum,Titel,Betrag,Kategorie,Unterkategorie,Wiederkehrend,Erstellt von\n'; const csvSafe = (val) => { let s = String(val || '').replace(/"/g, '""'); if (/^[=+\-@\t\r]/.test(s)) s = "'" + s; @@ -167,6 +231,7 @@ router.get('/export', (req, res) => { csvSafe(e.title), e.amount.toFixed(2).replace('.', ','), e.category, + e.subcategory || '', e.is_recurring ? 'Ja' : 'Nein', csvSafe(e.creator_name), ].join(',') @@ -187,7 +252,70 @@ router.get('/export', (req, res) => { * Response: { data: { categories } } */ router.get('/meta', (req, res) => { - res.json({ data: { categories: VALID_CATEGORIES } }); + res.json({ data: loadBudgetMeta() }); +}); + +router.post('/categories', (req, res) => { + try { + const vName = str(req.body.name, 'Name', { max: MAX_SHORT }); + const vType = oneOf(req.body.type || 'expense', ['expense', 'income'], 'Typ'); + const errors = collectErrors([vName, vType]); + if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); + + const conflict = db.get().prepare(` + SELECT key FROM budget_categories WHERE type = ? AND name = ? COLLATE NOCASE + `).get(vType.value, vName.value); + if (conflict) return res.status(409).json({ error: 'Kategorie existiert bereits.', code: 409 }); + + const maxOrder = db.get().prepare(` + SELECT COALESCE(MAX(sort_order), -1) AS m FROM budget_categories WHERE type = ? + `).get(vType.value).m; + const key = uniqueKey('budget_categories', vName.value); + + db.get().prepare(` + INSERT INTO budget_categories (key, name, type, sort_order) VALUES (?, ?, ?, ?) + `).run(key, vName.value, vType.value, maxOrder + 1); + + const cat = db.get().prepare('SELECT key, name, type, sort_order FROM budget_categories WHERE key = ?').get(key); + res.status(201).json({ data: cat }); + } catch (err) { + log.error('POST /categories Fehler:', err); + res.status(500).json({ error: 'Interner Fehler', code: 500 }); + } +}); + +router.post('/categories/:categoryKey/subcategories', (req, res) => { + try { + const cat = db.get().prepare(` + SELECT * FROM budget_categories WHERE key = ? AND type = 'expense' + `).get(req.params.categoryKey); + if (!cat) return res.status(404).json({ error: 'Kategorie nicht gefunden.', code: 404 }); + + const vName = str(req.body.name, 'Name', { max: MAX_SHORT }); + if (vName.error) return res.status(400).json({ error: vName.error, code: 400 }); + + const conflict = db.get().prepare(` + SELECT key FROM budget_subcategories WHERE category_key = ? AND name = ? COLLATE NOCASE + `).get(cat.key, vName.value); + if (conflict) return res.status(409).json({ error: 'Unterkategorie existiert bereits.', code: 409 }); + + const maxOrder = db.get().prepare(` + SELECT COALESCE(MAX(sort_order), -1) AS m FROM budget_subcategories WHERE category_key = ? + `).get(cat.key).m; + const key = uniqueKey('budget_subcategories', `${cat.key}_${vName.value}`); + + db.get().prepare(` + INSERT INTO budget_subcategories (key, category_key, name, sort_order) VALUES (?, ?, ?, ?) + `).run(key, cat.key, vName.value, maxOrder + 1); + + const sub = db.get().prepare(` + SELECT key, category_key, name, sort_order FROM budget_subcategories WHERE key = ? + `).get(key); + res.status(201).json({ data: sub }); + } catch (err) { + log.error('POST /categories/:categoryKey/subcategories Fehler:', err); + res.status(500).json({ error: 'Interner Fehler', code: 500 }); + } }); // -------------------------------------------------------- @@ -220,7 +348,7 @@ router.get('/', (req, res) => { `; const params = [from, to]; - if (req.query.category && VALID_CATEGORIES.includes(req.query.category)) { + if (req.query.category && validCategoryKeys().includes(req.query.category)) { sql += ' AND b.category = ?'; params.push(req.query.category); } @@ -238,24 +366,29 @@ router.get('/', (req, res) => { /** * POST /api/v1/budget * Neuen Eintrag anlegen. - * Body: { title, amount, category?, date, is_recurring?, recurrence_rule? } + * Body: { title, amount, category?, subcategory?, date, is_recurring?, recurrence_rule? } * Response: { data: Entry } */ router.post('/', (req, res) => { try { const vTitle = str(req.body.title, 'Titel', { max: MAX_TITLE }); const vAmount = num(req.body.amount, 'Betrag', { required: true }); - const vCat = oneOf(req.body.category || 'Sonstiges', VALID_CATEGORIES, 'Kategorie'); + const fallbackCategory = defaultCategory(Number(req.body.amount) < 0 ? 'expense' : 'income'); + const vCat = oneOf(req.body.category || fallbackCategory, validCategoryKeys(), 'Kategorie'); const vDate = validateDate(req.body.date, 'Datum', true); const vRrule = rrule(req.body.recurrence_rule, 'Wiederholung'); const errors = collectErrors([vTitle, vAmount, vCat, vDate, vRrule]); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); + const subcategory = validateSubcategory(vCat.value, req.body.subcategory); + if (subcategory === null) { + return res.status(400).json({ error: 'Ungültige Unterkategorie.', code: 400 }); + } const result = db.get().prepare(` - INSERT INTO budget_entries (title, amount, category, date, is_recurring, recurrence_rule, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO budget_entries (title, amount, category, subcategory, date, is_recurring, recurrence_rule, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) `).run( - vTitle.value, vAmount.value, vCat.value || 'Sonstiges', vDate.value, + vTitle.value, vAmount.value, vCat.value || fallbackCategory, subcategory, vDate.value, req.body.is_recurring ? 1 : 0, vRrule.value, req.session.userId ); @@ -288,18 +421,26 @@ router.put('/:id', (req, res) => { const checks = []; if (req.body.title !== undefined) checks.push(str(req.body.title, 'Titel', { max: MAX_TITLE, required: false })); if (req.body.amount !== undefined) checks.push(num(req.body.amount, 'Betrag')); - if (req.body.category !== undefined) checks.push(oneOf(req.body.category, VALID_CATEGORIES, 'Kategorie')); + if (req.body.category !== undefined) checks.push(oneOf(req.body.category, validCategoryKeys(), 'Kategorie')); if (req.body.date !== undefined) checks.push(validateDate(req.body.date, 'Datum')); if (req.body.recurrence_rule !== undefined) checks.push(rrule(req.body.recurrence_rule, 'Wiederholung')); const errors = collectErrors(checks); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); - const { title, amount, category, date, is_recurring, recurrence_rule } = req.body; + const { title, amount, category, subcategory: requestedSubcategory, date, is_recurring, recurrence_rule } = req.body; + const nextCategory = category ?? entry.category; + const subcategory = requestedSubcategory !== undefined || category !== undefined + ? validateSubcategory(nextCategory, requestedSubcategory ?? entry.subcategory) + : undefined; + if (subcategory === null) { + return res.status(400).json({ error: 'Ungültige Unterkategorie.', code: 400 }); + } db.get().prepare(` UPDATE budget_entries SET title = COALESCE(?, title), amount = COALESCE(?, amount), category = COALESCE(?, category), + subcategory = COALESCE(?, subcategory), date = COALESCE(?, date), is_recurring = COALESCE(?, is_recurring), recurrence_rule = ? @@ -308,6 +449,7 @@ router.put('/:id', (req, res) => { title?.trim() ?? null, amount !== undefined ? Number(amount) : null, category ?? null, + subcategory !== undefined ? subcategory : null, date ?? null, is_recurring !== undefined ? (is_recurring ? 1 : 0) : null, recurrence_rule !== undefined ? (recurrence_rule || null) : entry.recurrence_rule, diff --git a/test-notes-contacts-budget.js b/test-notes-contacts-budget.js index 0e14785..a2cb3e1 100644 --- a/test-notes-contacts-budget.js +++ b/test-notes-contacts-budget.js @@ -178,30 +178,30 @@ console.log('\n[Budget-Test] Einnahmen, Ausgaben, Saldo, Aggregation, CSV-Vorber let bId1, bId2, bId3, bId4; -test('Ausgabe eintragen (Lebensmittel)', () => { - const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, date, created_by) - VALUES ('REWE', -85.40, 'Lebensmittel', '2026-03-10', ?)`).run(uid); +test('Ausgabe eintragen (Supermarkt)', () => { + const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, subcategory, date, created_by) + VALUES ('REWE', -85.40, 'food', 'groceries', '2026-03-10', ?)`).run(uid); bId1 = r.lastInsertRowid; assert(bId1 > 0); }); test('Einnahme eintragen (Gehalt)', () => { const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, date, created_by) - VALUES ('Gehalt März', 2800.00, 'Sonstiges', '2026-03-01', ?)`).run(uid); + VALUES ('Gehalt März', 2800.00, 'Sonstiges Einkommen', '2026-03-01', ?)`).run(uid); bId2 = r.lastInsertRowid; assert(bId2 > 0); }); -test('Ausgabe (Miete)', () => { - const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, date, is_recurring, created_by) - VALUES ('Miete', -950.00, 'Miete', '2026-03-01', 1, ?)`).run(uid); +test('Ausgabe (Aluguel / Prestação)', () => { + const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, subcategory, date, is_recurring, created_by) + VALUES ('Miete', -950.00, 'housing', 'rent_mortgage', '2026-03-01', 1, ?)`).run(uid); bId3 = r.lastInsertRowid; assert(bId3 > 0); }); test('Ausgabe im anderen Monat (April)', () => { - const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, date, created_by) - VALUES ('Strom April', -55.00, 'Sonstiges', '2026-04-15', ?)`).run(uid); + const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, subcategory, date, created_by) + VALUES ('Strom April', -55.00, 'housing', 'utilities', '2026-04-15', ?)`).run(uid); bId4 = r.lastInsertRowid; assert(bId4 > 0); }); @@ -258,12 +258,18 @@ test('Aggregation nach Kategorie', () => { GROUP BY category ORDER BY ABS(SUM(amount)) DESC `).all(); assert(cats.length >= 2, `Mindestens 2 Kategorien, erhalten ${cats.length}`); - // Miete sollte die größte Ausgabe sein - const miete = cats.find((c) => c.category === 'Miete'); - assert(miete, 'Miete in Kategorien vorhanden'); + // Housing should be the largest expense category. + const miete = cats.find((c) => c.category === 'housing'); + assert(miete, 'Housing in Kategorien vorhanden'); assert(Math.abs(miete.expenses + 950.00) < 0.01, `Miete-Ausgaben: ${miete.expenses}`); }); +test('Unterkategorie gespeichert', () => { + const r = db.prepare('SELECT category, subcategory FROM budget_entries WHERE id = ?').get(bId1); + assert(r.category === 'food', `Kategorie: ${r.category}`); + assert(r.subcategory === 'groceries', `Unterkategorie: ${r.subcategory}`); +}); + test('Wiederkehrend-Flag korrekt', () => { const r = db.prepare('SELECT is_recurring FROM budget_entries WHERE id = ?').get(bId3); assert(r.is_recurring === 1, 'Miete ist wiederkehrend');