diff --git a/public/locales/ar.json b/public/locales/ar.json index e8e5cbf..c436961 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -477,7 +477,54 @@ "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..309c515 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -477,7 +477,54 @@ "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..fdb4036 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -477,7 +477,54 @@ "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..6cf9582 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -477,7 +477,54 @@ "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..01762ca 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -477,7 +477,54 @@ "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..0ddfd0b 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -477,7 +477,54 @@ "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..4f98a3f 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -477,7 +477,54 @@ "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..31c9cb9 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -477,7 +477,54 @@ "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..20d2d7f 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -477,7 +477,54 @@ "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/auth.js b/server/auth.js index ecb372c..31a62ec 100644 --- a/server/auth.js +++ b/server/auth.js @@ -136,7 +136,7 @@ function requireAuth(req, res, next) { if (req.session && req.session.userId) { return next(); } - res.status(401).json({ error: 'Nicht authentifiziert.', code: 401 }); + res.status(401).json({ error: 'Not authenticated.', code: 401 }); } /** @@ -146,7 +146,7 @@ function requireAdmin(req, res, next) { if (req.session && req.session.role === 'admin') { return next(); } - res.status(403).json({ error: 'Keine Berechtigung.', code: 403 }); + res.status(403).json({ error: 'Permission denied.', code: 403 }); } // -------------------------------------------------------- @@ -165,11 +165,11 @@ router.post('/login', loginLimiter, async (req, res) => { const { username, password } = req.body; if (!username || !password) { - return res.status(400).json({ error: 'Benutzername und Passwort erforderlich.', code: 400 }); + return res.status(400).json({ error: 'Username and password are required.', code: 400 }); } if (username.length > 64 || password.length > 1024) { - return res.status(400).json({ error: 'Eingabe zu lang.', code: 400 }); + return res.status(400).json({ error: 'Input is too long.', code: 400 }); } const user = db.get().prepare('SELECT * FROM users WHERE username = ?').get(username); @@ -177,18 +177,18 @@ router.post('/login', loginLimiter, async (req, res) => { if (!user) { // Timing-Attack-Schutz: trotzdem bcrypt ausführen await bcrypt.compare(password, '$2b$12$invalidhashfortimingprotection000000000000000000000'); - return res.status(401).json({ error: 'Ungültige Anmeldedaten.', code: 401 }); + return res.status(401).json({ error: 'Invalid credentials.', code: 401 }); } const valid = await bcrypt.compare(password, user.password_hash); if (!valid) { - return res.status(401).json({ error: 'Ungültige Anmeldedaten.', code: 401 }); + return res.status(401).json({ error: 'Invalid credentials.', code: 401 }); } req.session.regenerate((err) => { if (err) { - log.error('Session-Regenerierung fehlgeschlagen:', err); - return res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('Session regeneration failed:', err); + return res.status(500).json({ error: 'Internal server error.', code: 500 }); } req.session.userId = user.id; @@ -215,8 +215,8 @@ router.post('/login', loginLimiter, async (req, res) => { }); }); } catch (err) { - log.error('Login-Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('Login error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -227,8 +227,8 @@ router.post('/login', loginLimiter, async (req, res) => { router.post('/logout', requireAuth, csrfMiddleware, (req, res) => { req.session.destroy((err) => { if (err) { - log.error('Logout-Fehler:', err); - return res.status(500).json({ error: 'Logout fehlgeschlagen.', code: 500 }); + log.error('Logout error:', err); + return res.status(500).json({ error: 'Logout failed.', code: 500 }); } res.clearCookie('oikos.sid'); res.json({ ok: true }); @@ -246,7 +246,7 @@ router.post('/setup', loginLimiter, async (req, res) => { try { const { count } = db.get().prepare('SELECT COUNT(*) as count FROM users').get(); if (count > 0) { - return res.status(403).json({ error: 'Setup bereits abgeschlossen.', code: 403 }); + return res.status(403).json({ error: 'Setup has already been completed.', code: 403 }); } const username = (req.body.username || '').trim(); @@ -254,16 +254,16 @@ router.post('/setup', loginLimiter, async (req, res) => { const { password } = req.body; if (!username || !display_name || !password) { - return res.status(400).json({ error: 'Benutzername, Anzeigename und Passwort erforderlich.', code: 400 }); + return res.status(400).json({ error: 'Username, display name, and password are required.', code: 400 }); } if (!/^[a-zA-Z0-9._-]{3,64}$/.test(username)) { - return res.status(400).json({ error: 'Benutzername muss 3-64 Zeichen lang sein und darf nur Buchstaben, Zahlen, Punkte, Bindestriche und Unterstriche enthalten.', code: 400 }); + return res.status(400).json({ error: 'Username must be 3-64 characters long and may only contain letters, numbers, dots, hyphens, and underscores.', code: 400 }); } if (display_name.length > 128) { - return res.status(400).json({ error: 'Anzeigename darf maximal 128 Zeichen lang sein.', code: 400 }); + return res.status(400).json({ error: 'Display name may be at most 128 characters long.', code: 400 }); } if (password.length < 8) { - return res.status(400).json({ error: 'Passwort muss mindestens 8 Zeichen haben.', code: 400 }); + return res.status(400).json({ error: 'Password must be at least 8 characters long.', code: 400 }); } const avatarColor = avatarColors[Math.floor(Math.random() * avatarColors.length)]; @@ -278,10 +278,10 @@ router.post('/setup', loginLimiter, async (req, res) => { }); } catch (err) { if (err.message?.includes('UNIQUE constraint')) { - return res.status(409).json({ error: 'Benutzername bereits vergeben.', code: 409 }); + return res.status(409).json({ error: 'Username is already taken.', code: 409 }); } log.error('Setup error:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -297,7 +297,7 @@ router.get('/me', requireAuth, (req, res) => { if (!user) { req.session.destroy(() => {}); - return res.status(401).json({ error: 'Benutzer nicht gefunden.', code: 401 }); + return res.status(401).json({ error: 'User not found.', code: 401 }); } // CSRF-Token erneuern falls vorhanden (wichtig fuer iOS-PWA-Resume: @@ -315,8 +315,8 @@ router.get('/me', requireAuth, (req, res) => { res.json({ user, csrfToken: req.session.csrfToken }); } catch (err) { - log.error('/me Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('/me error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -332,8 +332,8 @@ router.get('/users', requireAuth, requireAdmin, (req, res) => { .all(); res.json({ data: users }); } catch (err) { - log.error('Users-Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('Users error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -348,23 +348,23 @@ router.post('/users', requireAuth, requireAdmin, csrfMiddleware, async (req, res const { username, display_name, password, avatar_color = '#007AFF', role = 'member' } = req.body; if (!username || !display_name || !password) { - return res.status(400).json({ error: 'Benutzername, Anzeigename und Passwort erforderlich.', code: 400 }); + return res.status(400).json({ error: 'Username, display name, and password are required.', code: 400 }); } if (password.length < 8) { - return res.status(400).json({ error: 'Passwort muss mindestens 8 Zeichen haben.', code: 400 }); + return res.status(400).json({ error: 'Password must be at least 8 characters long.', code: 400 }); } if (!/^[a-zA-Z0-9._-]{3,64}$/.test(username)) { - return res.status(400).json({ error: 'Benutzername muss 3-64 Zeichen lang sein und darf nur Buchstaben, Zahlen, Punkte, Bindestriche und Unterstriche enthalten.', code: 400 }); + return res.status(400).json({ error: 'Username must be 3-64 characters long and may only contain letters, numbers, dots, hyphens, and underscores.', code: 400 }); } if (display_name.length > 128) { - return res.status(400).json({ error: 'Anzeigename darf maximal 128 Zeichen lang sein.', code: 400 }); + return res.status(400).json({ error: 'Display name may be at most 128 characters long.', code: 400 }); } if (!['admin', 'member'].includes(role)) { - return res.status(400).json({ error: 'Ungültige Rolle.', code: 400 }); + return res.status(400).json({ error: 'Invalid role.', code: 400 }); } const hash = await bcrypt.hash(password, 12); @@ -381,10 +381,10 @@ router.post('/users', requireAuth, requireAdmin, csrfMiddleware, async (req, res }); } catch (err) { if (err.message && err.message.includes('UNIQUE constraint')) { - return res.status(409).json({ error: 'Benutzername bereits vergeben.', code: 409 }); + return res.status(409).json({ error: 'Username is already taken.', code: 409 }); } - log.error('User-Erstellen-Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('User creation error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -399,17 +399,17 @@ router.patch('/me/password', requireAuth, csrfMiddleware, async (req, res) => { const { current_password, new_password } = req.body; if (!current_password || !new_password) { - return res.status(400).json({ error: 'Aktuelles und neues Passwort erforderlich.', code: 400 }); + return res.status(400).json({ error: 'Current and new password are required.', code: 400 }); } if (new_password.length < 8) { - return res.status(400).json({ error: 'Neues Passwort muss mindestens 8 Zeichen haben.', code: 400 }); + return res.status(400).json({ error: 'New password must be at least 8 characters long.', code: 400 }); } const user = db.get().prepare('SELECT password_hash FROM users WHERE id = ?').get(req.session.userId); - if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden.', code: 404 }); + if (!user) return res.status(404).json({ error: 'User not found.', code: 404 }); const valid = await bcrypt.compare(current_password, user.password_hash); - if (!valid) return res.status(401).json({ error: 'Aktuelles Passwort falsch.', code: 401 }); + if (!valid) return res.status(401).json({ error: 'Current password is incorrect.', code: 401 }); const hash = await bcrypt.hash(new_password, 12); db.get().prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, req.session.userId); @@ -429,8 +429,8 @@ router.patch('/me/password', requireAuth, csrfMiddleware, async (req, res) => { res.json({ ok: true }); } catch (err) { - log.error('Passwort-Aendern-Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('Password change error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -444,13 +444,13 @@ router.delete('/users/:id', requireAuth, requireAdmin, csrfMiddleware, (req, res const userId = parseInt(req.params.id, 10); if (userId === req.session.userId) { - return res.status(400).json({ error: 'Eigenes Konto kann nicht gelöscht werden.', code: 400 }); + return res.status(400).json({ error: 'You cannot delete your own account.', code: 400 }); } const result = db.get().prepare('DELETE FROM users WHERE id = ?').run(userId); if (result.changes === 0) { - return res.status(404).json({ error: 'Benutzer nicht gefunden.', code: 404 }); + return res.status(404).json({ error: 'User not found.', code: 404 }); } // Alle aktiven Sessions des geloeschten Users invalidieren @@ -466,8 +466,8 @@ router.delete('/users/:id', requireAuth, requireAdmin, csrfMiddleware, (req, res res.json({ ok: true }); } catch (err) { - log.error('User-Loeschen-Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('User deletion error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); 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..4ef2853 100644 --- a/server/db.js +++ b/server/db.js @@ -41,7 +41,7 @@ function init() { try { db.prepare('SELECT count(*) FROM sqlite_master').get(); } catch { - throw new Error('[DB] Falscher Verschlüsselungsschlüssel oder keine SQLCipher-Unterstützung.'); + throw new Error('[DB] Wrong encryption key or SQLCipher support is unavailable.'); } } @@ -52,7 +52,7 @@ function init() { migrate(); - log.info(`Verbunden: ${DB_PATH} | Schema v${currentVersion()}`); + log.info(`Connected: ${DB_PATH} | Schema v${currentVersion()}`); return db; } @@ -67,7 +67,7 @@ function init() { const MIGRATIONS = [ { version: 1, - description: 'Initiales Schema', + description: 'Initial schema', up: ` -- Benutzer CREATE TABLE IF NOT EXISTS users ( @@ -269,7 +269,7 @@ const MIGRATIONS = [ }, { version: 2, - description: 'Sync-Konfigurationstabelle für Google/Apple Calendar', + description: 'Sync configuration table for Google/Apple Calendar', up: ` CREATE TABLE IF NOT EXISTS sync_config ( key TEXT PRIMARY KEY, @@ -282,7 +282,7 @@ const MIGRATIONS = [ }, { version: 3, - description: 'Wiederkehrende Budget-Einträge: parent-Referenz und Skip-Tabelle', + description: 'Recurring budget entries: parent reference and skip table', up: ` ALTER TABLE budget_entries ADD COLUMN recurrence_parent_id INTEGER REFERENCES budget_entries(id) ON DELETE SET NULL; @@ -298,7 +298,7 @@ const MIGRATIONS = [ }, { version: 4, - description: 'Priorität "none" erlauben und als Default setzen', + description: 'Allow "none" priority and set it as default', up: ` -- SQLite erlaubt kein ALTER CHECK, daher Tabelle neu erstellen CREATE TABLE tasks_new ( @@ -333,7 +333,7 @@ const MIGRATIONS = [ }, { version: 5, - description: 'Einkaufskategorien als eigene Tabelle (anpassbar, sortierbar)', + description: 'Shopping categories as a separate table (customizable, sortable)', up: ` CREATE TABLE IF NOT EXISTS shopping_categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -357,21 +357,21 @@ const MIGRATIONS = [ }, { version: 6, - description: 'Rezept-URL für Mahlzeiten', + description: 'Recipe URL for meals', up: ` ALTER TABLE meals ADD COLUMN recipe_url TEXT; `, }, { version: 7, - description: 'Kategorie pro Zutat für Einkaufslisten-Transfer', + description: 'Category per ingredient for shopping list transfer', up: ` ALTER TABLE meal_ingredients ADD COLUMN category TEXT NOT NULL DEFAULT 'Sonstiges'; `, }, { version: 8, - description: 'Erinnerungen (Reminders) für Aufgaben und Kalender-Events', + description: 'Reminders for tasks and calendar events', up: ` CREATE TABLE IF NOT EXISTS reminders ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -390,7 +390,7 @@ const MIGRATIONS = [ }, { version: 9, - description: 'Task-Kategorien auf englische Schlüssel migrieren', + description: 'Migrate task categories to English keys', up: ` UPDATE tasks SET category = CASE category WHEN 'Haushalt' THEN 'household' @@ -407,7 +407,7 @@ const MIGRATIONS = [ }, { version: 10, - description: 'ICS-Abonnements Tabelle', + description: 'ICS subscriptions table', up: ` CREATE TABLE IF NOT EXISTS ics_subscriptions ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -477,7 +477,7 @@ const MIGRATIONS = [ }, { version: 12, - description: 'calendar_events: partiellen Unique-Index durch vollständigen ersetzen (ON CONFLICT support)', + description: 'calendar_events: replace partial unique index with full index (ON CONFLICT support)', up: ` DROP INDEX IF EXISTS idx_calendar_sub_extid; CREATE UNIQUE INDEX idx_calendar_sub_extid @@ -486,7 +486,7 @@ const MIGRATIONS = [ }, { version: 13, - description: 'Rezepte-Tabelle und Mahlzeiten-Verknuepfung', + description: 'Recipes table and meal association', up: ` CREATE TABLE IF NOT EXISTS recipes ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -525,7 +525,7 @@ const MIGRATIONS = [ }, { version: 14, - description: 'Externe Kalender-Metadaten (Name, Farbe) und Verknüpfung mit Events', + description: 'External calendar metadata (name, color) and event association', up: ` CREATE TABLE IF NOT EXISTS external_calendars ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -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 expense categories as stable keys with subcategories', + 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: 'Move budget categories and subcategories to separate tables', + 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', 'Housing / Home', 'expense', 0), + ('food', 'Food', 'expense', 1), + ('transport', 'Transport', 'expense', 2), + ('personal_health', 'Personal Care / Health', 'expense', 3), + ('leisure', 'Leisure and Entertainment', 'expense', 4), + ('shopping_clothing', 'Shopping and Clothing', 'expense', 5), + ('education', 'Education', 'expense', 6), + ('financial_other', 'Financial Services and Other', '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', 'Rent / Mortgage', 0), + ('condominium', 'housing', 'Condominium fees', 1), + ('utilities', 'housing', 'Electricity / Water / Gas', 2), + ('internet_tv_phone', 'housing', 'Internet / TV / Phone', 3), + ('renovation_maintenance', 'housing', 'Renovation / Maintenance', 4), + ('cleaning', 'housing', 'Cleaning', 5), + ('groceries', 'food', 'Groceries', 0), + ('restaurants_bars', 'food', 'Restaurants / Bars', 1), + ('snacks_fast_food', 'food', 'Snacks / Fast Food', 2), + ('bakery', 'food', 'Bakery', 3), + ('fuel', 'transport', 'Fuel', 0), + ('parking_tolls', 'transport', 'Parking / Tolls', 1), + ('public_transport', 'transport', 'Public transport', 2), + ('apps_taxi', 'transport', 'Apps / Taxi', 3), + ('maintenance_insurance', 'transport', 'Maintenance / Insurance', 4), + ('pharmacy', 'personal_health', 'Pharmacy', 0), + ('health_insurance', 'personal_health', 'Health insurance', 1), + ('gym_sports', 'personal_health', 'Gym / Sports', 2), + ('beauty_cosmetics', 'personal_health', 'Beauty / Cosmetics', 3), + ('travel', 'leisure', 'Travel', 0), + ('streaming', 'leisure', 'Streaming', 1), + ('events', 'leisure', 'Events', 2), + ('hobbies', 'leisure', 'Hobbies', 3), + ('clothes_shoes', 'shopping_clothing', 'Clothes / Shoes', 0), + ('electronics', 'shopping_clothing', 'Electronics', 1), + ('gifts', 'shopping_clothing', 'Gifts', 2), + ('courses_college', 'education', 'Courses / College', 0), + ('school_supplies', 'education', 'School supplies', 1), + ('languages', 'education', 'Languages', 2), + ('loans_interest', 'financial_other', 'Loans / Interest', 0), + ('bank_fees', 'financial_other', 'Bank fees', 1), + ('insurance_other', 'financial_other', 'Insurance', 2), + ('investments', 'financial_other', 'Investments', 3), + ('taxes', 'financial_other', 'Taxes', 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; + `, + }, ]; /** @@ -571,7 +698,7 @@ function migrate() { db.exec(migration.up); db.prepare('INSERT INTO schema_migrations (version, description) VALUES (?, ?)') .run(migration.version, migration.description); - log.info(`Migration ${migration.version} angewendet: ${migration.description}`); + log.info(`Migration ${migration.version} applied: ${migration.description}`); }); for (const migration of pending) { @@ -602,7 +729,7 @@ function currentVersion() { * @returns {import('better-sqlite3').Database} */ function get() { - if (!db) throw new Error('[DB] Nicht initialisiert - init() zuerst aufrufen.'); + if (!db) throw new Error('[DB] Not initialized - call init() first.'); return db; } diff --git a/server/index.js b/server/index.js index 8f49777..120d75b 100644 --- a/server/index.js +++ b/server/index.js @@ -90,10 +90,10 @@ app.use(express.urlencoded({ extended: true, limit: '1mb' })); // JSON-Parse-Fehler abfangen (gibt sonst HTML zurück) app.use((err, req, res, next) => { if (err.type === 'entity.parse.failed') { - return res.status(400).json({ error: 'Ungültiges JSON im Request-Body.', code: 400 }); + return res.status(400).json({ error: 'Invalid JSON in request body.', code: 400 }); } if (err.type === 'entity.too.large') { - return res.status(413).json({ error: 'Request-Body zu groß (max. 1 MB).', code: 413 }); + return res.status(413).json({ error: 'Request body too large (max. 1 MB).', code: 413 }); } next(err); }); @@ -151,7 +151,7 @@ const apiLimiter = rateLimit({ max: 300, // 300 Requests/Minute pro IP (großzügig für Familien-App) standardHeaders: true, legacyHeaders: false, - message: { error: 'Zu viele Anfragen. Bitte warte kurz.', code: 429 }, + message: { error: 'Too many requests. Please wait a moment.', code: 429 }, skip: (req) => req.path === '/health', // Health-Check ausgenommen }); app.use('/api/', apiLimiter); @@ -198,7 +198,7 @@ const spaLimiter = rateLimit({ max: 200, standardHeaders: true, legacyHeaders: false, - message: { error: 'Zu viele Anfragen. Bitte warte kurz.', code: 429 }, + message: { error: 'Too many requests. Please wait a moment.', code: 429 }, }); // -------------------------------------------------------- @@ -206,7 +206,7 @@ const spaLimiter = rateLimit({ // -------------------------------------------------------- app.get('/{*path}', spaLimiter, (req, res) => { if (req.path.startsWith('/api/')) { - return res.status(404).json({ error: 'Nicht gefunden.', code: 404 }); + return res.status(404).json({ error: 'Not found.', code: 404 }); } res.sendFile(path.join(import.meta.dirname, '..', 'public', 'index.html')); }); @@ -215,8 +215,8 @@ app.get('/{*path}', spaLimiter, (req, res) => { // Globaler Error-Handler // -------------------------------------------------------- app.use((err, req, res, _next) => { - log.error('Unbehandelter Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('Unhandled error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); }); // -------------------------------------------------------- @@ -228,30 +228,30 @@ const SYNC_INTERVAL_MS = (parseInt(process.env.SYNC_INTERVAL_MINUTES, 10) || 15) async function runSync() { const { connected: googleConnected } = googleCalendar.getStatus(); if (googleConnected) { - googleCalendar.sync().catch((e) => logSync.error('Google Fehler:', e.message)); + googleCalendar.sync().catch((e) => logSync.error('Google error:', e.message)); } const { configured: appleConfigured } = appleCalendar.getStatus(); if (appleConfigured) { - appleCalendar.sync().catch((e) => logSync.error('Apple Fehler:', e.message)); + appleCalendar.sync().catch((e) => logSync.error('Apple error:', e.message)); } // ICS: kein Guard nötig — sync() fragt die DB ab und kehrt sofort zurück wenn keine Abonnements existieren - icsSubscription.sync().catch((e) => logSync.error('ICS Fehler:', e.message)); + icsSubscription.sync().catch((e) => logSync.error('ICS error:', e.message)); } // -------------------------------------------------------- // Server starten // -------------------------------------------------------- app.listen(PORT, () => { - logOikos.info(`Server laeuft auf Port ${PORT}`); - logOikos.info(`Umgebung: ${process.env.NODE_ENV || 'development'}`); + logOikos.info(`Server running on port ${PORT}`); + logOikos.info(`Environment: ${process.env.NODE_ENV || 'development'}`); // Erster Sync nach 10 Sekunden (warten bis DB vollständig initialisiert) setTimeout(() => { runSync(); setInterval(runSync, SYNC_INTERVAL_MS); - logSync.info(`Auto-Sync alle ${SYNC_INTERVAL_MS / 60_000} Minuten aktiv.`); + logSync.info(`Auto-sync active every ${SYNC_INTERVAL_MS / 60_000} minutes.`); }, 10_000); }); diff --git a/server/middleware/csrf.js b/server/middleware/csrf.js index 472ecd8..82ea63b 100644 --- a/server/middleware/csrf.js +++ b/server/middleware/csrf.js @@ -71,7 +71,7 @@ function csrfMiddleware(req, res, next) { } if (!tokenValid) { - return res.status(403).json({ error: 'Ungültiges CSRF-Token.', code: 403 }); + return res.status(403).json({ error: 'Invalid CSRF token.', code: 403 }); } next(); diff --git a/server/middleware/validate.js b/server/middleware/validate.js index dc6e861..ad1a46a 100644 --- a/server/middleware/validate.js +++ b/server/middleware/validate.js @@ -29,12 +29,12 @@ const RRULE_RE = /^(FREQ=(DAILY|WEEKLY|MONTHLY)(;INTERVAL=\d{1,2})?(;BYDAY=[A */ function str(val, field, { max = MAX_TITLE, required = true } = {}) { if (val === undefined || val === null || val === '') { - if (required) return { value: null, error: `${field} ist erforderlich.` }; + if (required) return { value: null, error: `${field} is required.` }; return { value: null, error: null }; } const s = String(val).trim(); - if (required && !s) return { value: null, error: `${field} darf nicht leer sein.` }; - if (s.length > max) return { value: null, error: `${field} darf maximal ${max} Zeichen haben.` }; + if (required && !s) return { value: null, error: `${field} must not be empty.` }; + if (s.length > max) return { value: null, error: `${field} may be at most ${max} characters long.` }; return { value: s || null, error: null }; } @@ -48,7 +48,7 @@ function str(val, field, { max = MAX_TITLE, required = true } = {}) { function oneOf(val, allowed, field) { if (val === undefined || val === null || val === '') return { value: null, error: null }; if (!allowed.includes(val)) - return { value: null, error: `${field} muss eines von: ${allowed.join(', ')} sein.` }; + return { value: null, error: `${field} must be one of: ${allowed.join(', ')}.` }; return { value: val, error: null }; } @@ -60,11 +60,11 @@ function oneOf(val, allowed, field) { */ function date(val, field, required = false) { if (!val) { - if (required) return { value: null, error: `${field} ist erforderlich.` }; + if (required) return { value: null, error: `${field} is required.` }; return { value: null, error: null }; } if (!/^\d{4}-\d{2}-\d{2}$/.test(String(val))) - return { value: null, error: `${field} muss im Format YYYY-MM-DD sein.` }; + return { value: null, error: `${field} must be in YYYY-MM-DD format.` }; return { value: String(val), error: null }; } @@ -74,7 +74,7 @@ function date(val, field, required = false) { function time(val, field) { if (!val) return { value: null, error: null }; if (!/^\d{2}:\d{2}$/.test(String(val))) - return { value: null, error: `${field} muss im Format HH:MM sein.` }; + return { value: null, error: `${field} must be in HH:MM format.` }; return { value: String(val), error: null }; } @@ -83,11 +83,11 @@ function time(val, field) { */ function num(val, field, { required = false } = {}) { if (val === undefined || val === null || val === '') { - if (required) return { value: null, error: `${field} ist erforderlich.` }; + if (required) return { value: null, error: `${field} is required.` }; return { value: null, error: null }; } const n = Number(val); - if (!isFinite(n)) return { value: null, error: `${field} muss eine gültige Zahl sein.` }; + if (!isFinite(n)) return { value: null, error: `${field} must be a valid number.` }; return { value: n, error: null }; } @@ -97,7 +97,7 @@ function num(val, field, { required = false } = {}) { function color(val, field) { if (!val) return { value: null, error: null }; if (!/^#[0-9A-Fa-f]{6}$/.test(String(val))) - return { value: null, error: `${field} muss ein gültiger HEX-Farbwert sein (#RRGGBB).` }; + return { value: null, error: `${field} must be a valid HEX color (#RRGGBB).` }; return { value: String(val), error: null }; } @@ -115,11 +115,11 @@ function collectErrors(results) { */ function datetime(val, field, required = false) { if (!val) { - if (required) return { value: null, error: `${field} ist erforderlich.` }; + if (required) return { value: null, error: `${field} is required.` }; return { value: null, error: null }; } if (!DATETIME_RE.test(String(val))) - return { value: null, error: `${field} muss im Format YYYY-MM-DD oder YYYY-MM-DDTHH:MM sein.` }; + return { value: null, error: `${field} must be in YYYY-MM-DD or YYYY-MM-DDTHH:MM format.` }; return { value: String(val), error: null }; } @@ -129,7 +129,7 @@ function datetime(val, field, required = false) { function month(val, field) { if (!val) return { value: null, error: null }; if (!MONTH_RE.test(String(val))) - return { value: null, error: `${field} muss im Format YYYY-MM sein.` }; + return { value: null, error: `${field} must be in YYYY-MM format.` }; return { value: String(val), error: null }; } @@ -140,10 +140,10 @@ function rrule(val, field) { if (!val) return { value: null, error: null }; const s = String(val).trim(); if (s.length > MAX_RRULE) - return { value: null, error: `${field} darf maximal ${MAX_RRULE} Zeichen haben.` }; + return { value: null, error: `${field} may be at most ${MAX_RRULE} characters long.` }; // Grundlegende Struktur: KEY=VALUE;KEY=VALUE if (!/^FREQ=(DAILY|WEEKLY|MONTHLY)/.test(s)) - return { value: null, error: `${field}: Ungültige Wiederholungsregel.` }; + return { value: null, error: `${field}: invalid recurrence rule.` }; return { value: s, error: null }; } @@ -152,7 +152,7 @@ function rrule(val, field) { */ function id(val, field) { const n = parseInt(val, 10); - if (!n || n < 1) return { value: null, error: `${field} muss eine positive Zahl sein.` }; + if (!n || n < 1) return { value: null, error: `${field} must be a positive number.` }; return { value: n, error: null }; } diff --git a/server/routes/budget.js b/server/routes/budget.js index c5bfdf4..304b3df 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 @@ -127,7 +191,7 @@ router.get('/summary', (req, res) => { }); } catch (err) { log.error('', err); - res.status(500).json({ error: 'Interner Fehler', code: 500 }); + res.status(500).json({ error: 'Internal error', code: 500 }); } }); @@ -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 = 'Date,Title,Amount,Category,Subcategory,Recurring,Created by\n'; const csvSafe = (val) => { let s = String(val || '').replace(/"/g, '""'); if (/^[=+\-@\t\r]/.test(s)) s = "'" + s; @@ -167,7 +231,8 @@ router.get('/export', (req, res) => { csvSafe(e.title), e.amount.toFixed(2).replace('.', ','), e.category, - e.is_recurring ? 'Ja' : 'Nein', + e.subcategory || '', + e.is_recurring ? 'Yes' : 'No', csvSafe(e.creator_name), ].join(',') ).join('\n'); @@ -177,7 +242,7 @@ router.get('/export', (req, res) => { res.send('\uFEFF' + header + rows); // BOM für Excel } catch (err) { log.error('', err); - res.status(500).json({ error: 'Interner Fehler', code: 500 }); + res.status(500).json({ error: 'Internal error', code: 500 }); } }); @@ -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: 'Category already exists.', 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 error:', err); + res.status(500).json({ error: 'Internal error', 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: 'Category not found.', 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: 'Subcategory already exists.', 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 error:', err); + res.status(500).json({ error: 'Internal error', 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); } @@ -231,31 +359,36 @@ router.get('/', (req, res) => { res.json({ data: entries }); } catch (err) { log.error('', err); - res.status(500).json({ error: 'Interner Fehler', code: 500 }); + res.status(500).json({ error: 'Internal error', code: 500 }); } }); /** * 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: 'Invalid subcategory.', 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 ); @@ -269,7 +402,7 @@ router.post('/', (req, res) => { res.status(201).json({ data: entry }); } catch (err) { log.error('', err); - res.status(500).json({ error: 'Interner Fehler', code: 500 }); + res.status(500).json({ error: 'Internal error', code: 500 }); } }); @@ -283,23 +416,31 @@ router.put('/:id', (req, res) => { try { const id = parseInt(req.params.id, 10); const entry = db.get().prepare('SELECT * FROM budget_entries WHERE id = ?').get(id); - if (!entry) return res.status(404).json({ error: 'Eintrag nicht gefunden', code: 404 }); + if (!entry) return res.status(404).json({ error: 'Entry not found', code: 404 }); 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: 'Invalid subcategory.', 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, @@ -322,7 +464,7 @@ router.put('/:id', (req, res) => { res.json({ data: updated }); } catch (err) { log.error('', err); - res.status(500).json({ error: 'Interner Fehler', code: 500 }); + res.status(500).json({ error: 'Internal error', code: 500 }); } }); @@ -335,7 +477,7 @@ router.delete('/:id', (req, res) => { try { const id = parseInt(req.params.id, 10); const entry = db.get().prepare('SELECT * FROM budget_entries WHERE id = ?').get(id); - if (!entry) return res.status(404).json({ error: 'Eintrag nicht gefunden', code: 404 }); + if (!entry) return res.status(404).json({ error: 'Entry not found', code: 404 }); db.get().prepare('DELETE FROM budget_entries WHERE id = ?').run(id); @@ -350,7 +492,7 @@ router.delete('/:id', (req, res) => { res.status(204).end(); } catch (err) { log.error('', err); - res.status(500).json({ error: 'Interner Fehler', code: 500 }); + res.status(500).json({ error: 'Internal error', code: 500 }); } }); diff --git a/server/routes/calendar.js b/server/routes/calendar.js index b499af2..77a13bc 100644 --- a/server/routes/calendar.js +++ b/server/routes/calendar.js @@ -257,7 +257,7 @@ router.get('/google/callback', async (req, res) => { await googleCalendar.handleCallback(code); // Initialen Sync im Hintergrund starten (kein await - Redirect soll sofort erfolgen) - googleCalendar.sync().catch((e) => log.error('Initialer Sync fehlgeschlagen:', e.message)); + googleCalendar.sync().catch((e) => log.error('Initial sync failed:', e.message)); res.redirect('/settings?sync_ok=google'); } catch (err) { diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index 7e36483..9bb2f32 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -53,7 +53,7 @@ router.get('/', (req, res) => { LIMIT 5 `).all(now.toISOString()); } catch (err) { - log.error('upcomingEvents-Fehler:', err.message); + log.error('upcomingEvents error:', err.message); result.upcomingEvents = []; } @@ -91,7 +91,7 @@ router.get('/', (req, res) => { result.urgentTasks = allOpen.slice(0, 5); } catch (err) { - log.error('urgentTasks-Fehler:', err.message); + log.error('urgentTasks error:', err.message); result.urgentTasks = []; } @@ -116,7 +116,7 @@ router.get('/', (req, res) => { END `).all(todayStr, ...visibleTypes); } catch (err) { - log.error('todayMeals-Fehler:', err.message); + log.error('todayMeals error:', err.message); result.todayMeals = []; } @@ -130,7 +130,7 @@ router.get('/', (req, res) => { LIMIT 3 `).all(); } catch (err) { - log.error('pinnedNotes-Fehler:', err.message); + log.error('pinnedNotes error:', err.message); result.pinnedNotes = []; } @@ -157,7 +157,7 @@ router.get('/', (req, res) => { } result.shoppingLists = lists; } catch (err) { - log.error('shoppingLists-Fehler:', err.message); + log.error('shoppingLists error:', err.message); result.shoppingLists = []; } @@ -172,8 +172,8 @@ router.get('/', (req, res) => { res.json(result); } catch (err) { - log.error('Kritischer Fehler:', err.message); - res.status(500).json({ error: 'Dashboard konnte nicht geladen werden.', code: 500 }); + log.error('Critical error:', err.message); + res.status(500).json({ error: 'Dashboard could not be loaded.', code: 500 }); } }); diff --git a/server/routes/recipes.js b/server/routes/recipes.js index e301bd3..17448d4 100644 --- a/server/routes/recipes.js +++ b/server/routes/recipes.js @@ -59,8 +59,8 @@ router.get('/', (_req, res) => { res.json({ data: recipes.map((r) => ({ ...r, ingredients: ingredientMap[r.id] || [] })) }); } catch (err) { - log.error('GET / Fehler:', err); - res.status(500).json({ error: 'Interner Fehler', code: 500 }); + log.error('GET / error:', err); + res.status(500).json({ error: 'Internal error', code: 500 }); } }); @@ -100,8 +100,8 @@ router.post('/', (req, res) => { const created = loadRecipeWithIngredients(recipeId); res.status(201).json({ data: created }); } catch (err) { - log.error('POST / Fehler:', err); - res.status(500).json({ error: 'Interner Fehler', code: 500 }); + log.error('POST / error:', err); + res.status(500).json({ error: 'Internal error', code: 500 }); } }); @@ -111,8 +111,8 @@ router.put('/:id', (req, res) => { if (!id) return res.status(400).json({ error: 'Ungueltige Rezept-ID', code: 400 }); const existing = db.get().prepare('SELECT id, created_by FROM recipes WHERE id = ?').get(id); - if (!existing) return res.status(404).json({ error: 'Rezept nicht gefunden', code: 404 }); - if (existing.created_by !== req.session.userId) return res.status(403).json({ error: 'Nicht autorisiert.', code: 403 }); + if (!existing) return res.status(404).json({ error: 'Recipe not found', code: 404 }); + if (existing.created_by !== req.session.userId) return res.status(403).json({ error: 'Not authorized.', code: 403 }); const { ingredients = [] } = req.body; @@ -147,27 +147,27 @@ router.put('/:id', (req, res) => { const updated = loadRecipeWithIngredients(id); res.json({ data: updated }); } catch (err) { - log.error('PUT /:id Fehler:', err); - res.status(500).json({ error: 'Interner Fehler', code: 500 }); + log.error('PUT /:id error:', err); + res.status(500).json({ error: 'Internal error', code: 500 }); } }); router.delete('/:id', (req, res) => { try { const id = parseInt(req.params.id, 10); - if (!id) return res.status(400).json({ error: 'Ungültige Rezept-ID.', code: 400 }); + if (!id) return res.status(400).json({ error: 'Invalid recipe ID.', code: 400 }); const existing = db.get().prepare('SELECT id, created_by FROM recipes WHERE id = ?').get(id); - if (!existing) return res.status(404).json({ error: 'Rezept nicht gefunden.', code: 404 }); - if (existing.created_by !== req.session.userId) return res.status(403).json({ error: 'Nicht autorisiert.', code: 403 }); + if (!existing) return res.status(404).json({ error: 'Recipe not found.', code: 404 }); + if (existing.created_by !== req.session.userId) return res.status(403).json({ error: 'Not authorized.', code: 403 }); const result = db.get().prepare('DELETE FROM recipes WHERE id = ?').run(id); - if (result.changes === 0) return res.status(404).json({ error: 'Rezept nicht gefunden', code: 404 }); + if (result.changes === 0) return res.status(404).json({ error: 'Recipe not found', code: 404 }); res.status(204).end(); } catch (err) { - log.error('DELETE /:id Fehler:', err); - res.status(500).json({ error: 'Interner Fehler', code: 500 }); + log.error('DELETE /:id error:', err); + res.status(500).json({ error: 'Internal error', code: 500 }); } }); diff --git a/server/routes/reminders.js b/server/routes/reminders.js index 00de8bc..e27c894 100644 --- a/server/routes/reminders.js +++ b/server/routes/reminders.js @@ -41,8 +41,8 @@ router.get('/pending', (req, res) => { res.json({ data: rows }); } catch (err) { - log.error('Fehler beim Laden fälliger Erinnerungen:', err.message); - res.status(500).json({ error: 'Interner Fehler.', code: 500 }); + log.error('Error loading due reminders:', err.message); + res.status(500).json({ error: 'Internal error.', code: 500 }); } }); @@ -69,8 +69,8 @@ router.get('/', (req, res) => { res.json({ data: row || null }); } catch (err) { - log.error('Fehler beim Laden der Erinnerung:', err.message); - res.status(500).json({ error: 'Interner Fehler.', code: 500 }); + log.error('Error loading reminder:', err.message); + res.status(500).json({ error: 'Internal error.', code: 500 }); } }); @@ -115,8 +115,8 @@ router.post('/', (req, res) => { const row = db.get().prepare('SELECT * FROM reminders WHERE id = ?').get(result.lastInsertRowid); res.status(201).json({ data: row }); } catch (err) { - log.error('Fehler beim Erstellen der Erinnerung:', err.message); - res.status(500).json({ error: 'Interner Fehler.', code: 500 }); + log.error('Error creating reminder:', err.message); + res.status(500).json({ error: 'Internal error.', code: 500 }); } }); @@ -145,8 +145,8 @@ router.patch('/:id/dismiss', (req, res) => { db.get().prepare('UPDATE reminders SET dismissed = 1 WHERE id = ?').run(reminderId); res.json({ data: { id: reminderId } }); } catch (err) { - log.error('Fehler beim Verwerfen der Erinnerung:', err.message); - res.status(500).json({ error: 'Interner Fehler.', code: 500 }); + log.error('Error dismissing reminder:', err.message); + res.status(500).json({ error: 'Internal error.', code: 500 }); } }); @@ -175,8 +175,8 @@ router.delete('/:id', (req, res) => { db.get().prepare('DELETE FROM reminders WHERE id = ?').run(reminderId); res.status(204).end(); } catch (err) { - log.error('Fehler beim Löschen der Erinnerung:', err.message); - res.status(500).json({ error: 'Interner Fehler.', code: 500 }); + log.error('Error deleting reminder:', err.message); + res.status(500).json({ error: 'Internal error.', code: 500 }); } }); @@ -202,8 +202,8 @@ router.delete('/', (req, res) => { res.status(204).end(); } catch (err) { - log.error('Fehler beim Löschen der Erinnerungen:', err.message); - res.status(500).json({ error: 'Interner Fehler.', code: 500 }); + log.error('Error deleting reminders:', err.message); + res.status(500).json({ error: 'Internal error.', code: 500 }); } }); diff --git a/server/routes/shopping.js b/server/routes/shopping.js index 6857a08..2d7fbae 100644 --- a/server/routes/shopping.js +++ b/server/routes/shopping.js @@ -39,8 +39,8 @@ router.get('/categories', (_req, res) => { try { res.json({ data: loadCategories() }); } catch (err) { - log.error('GET /categories Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('GET /categories error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -58,7 +58,7 @@ router.post('/categories', (req, res) => { const existing = db.get() .prepare('SELECT id FROM shopping_categories WHERE name = ? COLLATE NOCASE') .get(vName.value); - if (existing) return res.status(409).json({ error: 'Kategorie existiert bereits.', code: 409 }); + if (existing) return res.status(409).json({ error: 'Category already exists.', code: 409 }); const maxOrder = db.get() .prepare('SELECT COALESCE(MAX(sort_order), -1) AS m FROM shopping_categories') @@ -73,8 +73,8 @@ router.post('/categories', (req, res) => { .get(result.lastInsertRowid); res.status(201).json({ data: cat }); } catch (err) { - log.error('POST /categories Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('POST /categories error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -89,7 +89,7 @@ router.put('/categories/:catId', (req, res) => { const cat = db.get() .prepare('SELECT * FROM shopping_categories WHERE id = ?') .get(req.params.catId); - if (!cat) return res.status(404).json({ error: 'Kategorie nicht gefunden.', code: 404 }); + if (!cat) return res.status(404).json({ error: 'Category not found.', 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 }); @@ -97,7 +97,7 @@ router.put('/categories/:catId', (req, res) => { const conflict = db.get() .prepare('SELECT id FROM shopping_categories WHERE name = ? COLLATE NOCASE AND id != ?') .get(vName.value, cat.id); - if (conflict) return res.status(409).json({ error: 'Kategorie existiert bereits.', code: 409 }); + if (conflict) return res.status(409).json({ error: 'Category already exists.', code: 409 }); // Artikel, die die alte Kategorie nutzen, mitumbenennen db.get().transaction(() => { @@ -114,8 +114,8 @@ router.put('/categories/:catId', (req, res) => { .get(cat.id); res.json({ data: updated }); } catch (err) { - log.error('PUT /categories/:catId Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('PUT /categories/:catId error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -130,12 +130,12 @@ router.delete('/categories/:catId', (req, res) => { const cat = db.get() .prepare('SELECT * FROM shopping_categories WHERE id = ?') .get(req.params.catId); - if (!cat) return res.status(404).json({ error: 'Kategorie nicht gefunden.', code: 404 }); + if (!cat) return res.status(404).json({ error: 'Category not found.', code: 404 }); const total = db.get() .prepare('SELECT COUNT(*) AS c FROM shopping_categories') .get().c; - if (total <= 1) return res.status(400).json({ error: 'Letzte Kategorie kann nicht gelöscht werden.', code: 400 }); + if (total <= 1) return res.status(400).json({ error: 'The last category cannot be deleted.', code: 400 }); // Fallback-Kategorie: erste andere Kategorie nach sort_order const fallback = db.get() @@ -153,8 +153,8 @@ router.delete('/categories/:catId', (req, res) => { res.json({ ok: true }); } catch (err) { - log.error('DELETE /categories/:catId Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('DELETE /categories/:catId error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -177,8 +177,8 @@ router.patch('/categories/reorder', (req, res) => { res.json({ data: loadCategories() }); } catch (err) { - log.error('PATCH /categories/reorder Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('PATCH /categories/reorder error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -201,8 +201,8 @@ router.get('/suggestions', (req, res) => { res.json({ data: rows.map((r) => r.name) }); } catch (err) { - log.error('suggestions Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('suggestions error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -217,7 +217,7 @@ router.patch('/items/:itemId', (req, res) => { const item = db.get() .prepare('SELECT * FROM shopping_items WHERE id = ?') .get(req.params.itemId); - if (!item) return res.status(404).json({ error: 'Artikel nicht gefunden.', code: 404 }); + if (!item) return res.status(404).json({ error: 'Item not found.', code: 404 }); const { is_checked = item.is_checked, @@ -230,7 +230,7 @@ router.patch('/items/:itemId', (req, res) => { const validNames = validCategoryNames(); if (category && !validNames.includes(category)) - return res.status(400).json({ error: 'Ungültige Kategorie.', code: 400 }); + return res.status(400).json({ error: 'Invalid category.', code: 400 }); db.get().prepare(` UPDATE shopping_items @@ -243,8 +243,8 @@ router.patch('/items/:itemId', (req, res) => { .get(req.params.itemId); res.json({ data: updated }); } catch (err) { - log.error('PATCH items/:id Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('PATCH items/:id error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -259,11 +259,11 @@ router.delete('/items/:itemId', (req, res) => { .prepare('DELETE FROM shopping_items WHERE id = ?') .run(req.params.itemId); if (result.changes === 0) - return res.status(404).json({ error: 'Artikel nicht gefunden.', code: 404 }); + return res.status(404).json({ error: 'Item not found.', code: 404 }); res.json({ ok: true }); } catch (err) { - log.error('DELETE items/:id Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('DELETE items/:id error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -286,8 +286,8 @@ router.get('/', (req, res) => { `).all(); res.json({ data: lists }); } catch (err) { - log.error('GET / Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('GET / error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -311,8 +311,8 @@ router.post('/', (req, res) => { .get(result.lastInsertRowid); res.status(201).json({ data: list }); } catch (err) { - log.error('POST / Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('POST / error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -331,15 +331,15 @@ router.put('/:listId', (req, res) => { .prepare('UPDATE shopping_lists SET name = ? WHERE id = ?') .run(vName.value, req.params.listId); if (result.changes === 0) - return res.status(404).json({ error: 'Liste nicht gefunden.', code: 404 }); + return res.status(404).json({ error: 'List not found.', code: 404 }); const list = db.get() .prepare('SELECT * FROM shopping_lists WHERE id = ?') .get(req.params.listId); res.json({ data: list }); } catch (err) { - log.error('PUT /:listId Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('PUT /:listId error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -354,11 +354,11 @@ router.delete('/:listId', (req, res) => { .prepare('DELETE FROM shopping_lists WHERE id = ?') .run(req.params.listId); if (result.changes === 0) - return res.status(404).json({ error: 'Liste nicht gefunden.', code: 404 }); + return res.status(404).json({ error: 'List not found.', code: 404 }); res.json({ ok: true }); } catch (err) { - log.error('DELETE /:listId Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('DELETE /:listId error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -373,7 +373,7 @@ router.get('/:listId/items', (req, res) => { const list = db.get() .prepare('SELECT * FROM shopping_lists WHERE id = ?') .get(req.params.listId); - if (!list) return res.status(404).json({ error: 'Liste nicht gefunden.', code: 404 }); + if (!list) return res.status(404).json({ error: 'List not found.', code: 404 }); const categories = loadCategories(); const categoryOrder = categories.map((c, i) => `WHEN '${c.name.replace(/'/g, "''")}' THEN ${i}`).join(' '); @@ -389,8 +389,8 @@ router.get('/:listId/items', (req, res) => { res.json({ data: items, list, categories }); } catch (err) { - log.error('GET /:listId/items Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('GET /:listId/items error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -405,7 +405,7 @@ router.post('/:listId/items', (req, res) => { const list = db.get() .prepare('SELECT id FROM shopping_lists WHERE id = ?') .get(req.params.listId); - if (!list) return res.status(404).json({ error: 'Liste nicht gefunden.', code: 404 }); + if (!list) return res.status(404).json({ error: 'List not found.', code: 404 }); const validNames = validCategoryNames(); const defaultCat = validNames[0] ?? 'Sonstiges'; @@ -427,8 +427,8 @@ router.post('/:listId/items', (req, res) => { .get(result.lastInsertRowid); res.status(201).json({ data: item }); } catch (err) { - log.error('POST /:listId/items Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('POST /:listId/items error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -444,8 +444,8 @@ router.delete('/:listId/items/checked', (req, res) => { `).run(req.params.listId); res.json({ deleted: result.changes }); } catch (err) { - log.error('DELETE /:listId/items/checked Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('DELETE /:listId/items/checked error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); diff --git a/server/routes/tasks.js b/server/routes/tasks.js index 2950e1e..65afef5 100644 --- a/server/routes/tasks.js +++ b/server/routes/tasks.js @@ -103,8 +103,8 @@ router.get('/', (req, res) => { res.json({ data: db.get().prepare(sql).all(...params) }); } catch (err) { - log.error('GET / Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('GET / error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -122,13 +122,13 @@ router.get('/:id', (req, res) => { WHERE t.id = ? AND t.parent_task_id IS NULL `).get(req.params.id); - if (!task) return res.status(404).json({ error: 'Aufgabe nicht gefunden.', code: 404 }); + if (!task) return res.status(404).json({ error: 'Task not found.', code: 404 }); task.subtasks = loadSubtasks(task.id); res.json({ data: task }); } catch (err) { - log.error('GET /:id Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('GET /:id error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -161,7 +161,7 @@ router.post('/', (req, res) => { if (parent_task_id) { const parent = db.get().prepare('SELECT parent_task_id FROM tasks WHERE id = ?') .get(parent_task_id); - if (!parent) return res.status(404).json({ error: 'Übergeordnete Aufgabe nicht gefunden.', code: 404 }); + if (!parent) return res.status(404).json({ error: 'Parent task not found.', code: 404 }); if (parent.parent_task_id) return res.status(400).json({ error: 'Maximal 2 Verschachtelungsebenen erlaubt.', code: 400 }); } @@ -185,8 +185,8 @@ router.post('/', (req, res) => { res.status(201).json({ data: task }); } catch (err) { - log.error('POST / Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('POST / error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -200,7 +200,7 @@ router.post('/', (req, res) => { router.put('/:id', (req, res) => { try { const task = db.get().prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id); - if (!task) return res.status(404).json({ error: 'Aufgabe nicht gefunden.', code: 404 }); + if (!task) return res.status(404).json({ error: 'Task not found.', code: 404 }); const errors = validateTaskInput(req.body, false); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); @@ -237,8 +237,8 @@ router.put('/:id', (req, res) => { res.json({ data: updated }); } catch (err) { - log.error('PUT /:id Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('PUT /:id error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -252,13 +252,13 @@ router.patch('/:id/status', (req, res) => { try { const { status } = req.body; if (!VALID_STATUSES.includes(status)) - return res.status(400).json({ error: `Ungültiger Status. Erlaubt: ${VALID_STATUSES.join(', ')}`, code: 400 }); + return res.status(400).json({ error: `Invalid status. Allowed: ${VALID_STATUSES.join(', ')}`, code: 400 }); const result = db.get().prepare('UPDATE tasks SET status = ? WHERE id = ?') .run(status, req.params.id); if (result.changes === 0) - return res.status(404).json({ error: 'Aufgabe nicht gefunden.', code: 404 }); + return res.status(404).json({ error: 'Task not found.', code: 404 }); // Wiederkehrende Aufgabe: nächste Instanz erstellen wenn erledigt if (status === 'done') { @@ -281,8 +281,8 @@ router.patch('/:id/status', (req, res) => { res.json({ data: { id: Number(req.params.id), status } }); } catch (err) { - log.error('PATCH /:id/status Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('PATCH /:id/status error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -295,11 +295,11 @@ router.delete('/:id', (req, res) => { try { const result = db.get().prepare('DELETE FROM tasks WHERE id = ?').run(req.params.id); if (result.changes === 0) - return res.status(404).json({ error: 'Aufgabe nicht gefunden.', code: 404 }); + return res.status(404).json({ error: 'Task not found.', code: 404 }); res.json({ ok: true }); } catch (err) { - log.error('DELETE /:id Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('DELETE /:id error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); @@ -315,8 +315,8 @@ router.get('/meta/options', (req, res) => { ).all(); res.json({ users, priorities: VALID_PRIORITIES, statuses: VALID_STATUSES, categories: VALID_CATEGORIES }); } catch (err) { - log.error('GET /meta/options Fehler:', err); - res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + log.error('GET /meta/options error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); } }); diff --git a/server/routes/weather.js b/server/routes/weather.js index bb59ea4..dbddf49 100644 --- a/server/routes/weather.js +++ b/server/routes/weather.js @@ -45,7 +45,7 @@ router.get('/', async (req, res) => { const currentUrl = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&appid=${apiKey}&units=${units}&lang=${lang}`; const currentRes = await fetch(currentUrl, { signal: AbortSignal.timeout(8000) }); if (!currentRes.ok) { - log.warn(`API Fehler: ${currentRes.status}`); + log.warn(`API error: ${currentRes.status}`); return res.json({ data: null }); } const currentJson = await currentRes.json(); @@ -116,7 +116,7 @@ router.get('/', async (req, res) => { cache = { data, ts: Date.now() }; res.json({ data }); } catch (err) { - log.warn('Fehler:', err.message); + log.warn('Error:', err.message); res.json({ data: null }); // Fallback: Widget ausblenden, kein Error-Screen } }); @@ -145,8 +145,8 @@ router.get('/icon/:code', async (req, res) => { res.setHeader('Cache-Control', 'public, max-age=86400'); // 24 Stunden upstream.body.pipe(res); } catch (err) { - log.warn('Icon-Proxy Fehler:', err.message); - res.status(502).json({ error: 'Icon-Proxy fehlgeschlagen.', code: 502 }); + log.warn('Icon proxy error:', err.message); + res.status(502).json({ error: 'Icon proxy failed.', code: 502 }); } }); diff --git a/server/services/apple-calendar.js b/server/services/apple-calendar.js index b16d317..60ea995 100644 --- a/server/services/apple-calendar.js +++ b/server/services/apple-calendar.js @@ -80,7 +80,7 @@ function getCredentials() { function saveCredentials(url, username, password) { // Warnung wenn DB-Verschluesselung nicht aktiv - Credentials liegen dann im Klartext if (!process.env.DB_ENCRYPTION_KEY) { - log.warn('WARNUNG: DB_ENCRYPTION_KEY nicht gesetzt - CalDAV-Credentials werden unverschluesselt gespeichert.'); + log.warn('WARNING: DB_ENCRYPTION_KEY is not set - CalDAV credentials will be stored unencrypted.'); } cfgSet('apple_caldav_url', url); cfgSet('apple_username', username); @@ -89,7 +89,7 @@ function saveCredentials(url, username, password) { function clearCredentials() { ['apple_caldav_url', 'apple_username', 'apple_app_password', 'apple_last_sync'].forEach(cfgDel); - log.info('Verbindung getrennt.'); + log.info('Disconnected.'); } // -------------------------------------------------------- @@ -110,7 +110,7 @@ function getStatus() { */ async function testConnection() { const creds = getCredentials(); - if (!creds) throw new Error('[Apple] Keine Credentials konfiguriert.'); + if (!creds) throw new Error('[Apple] No credentials configured.'); const { createDAVClient } = await import('tsdav'); const client = await createDAVClient({ @@ -121,7 +121,7 @@ async function testConnection() { }); const calendars = await client.fetchCalendars(); - if (!calendars.length) throw new Error('[Apple] Verbunden, aber keine Kalender gefunden.'); + if (!calendars.length) throw new Error('[Apple] Connected, but no calendars found.'); return { ok: true, calendarCount: calendars.length }; } @@ -196,7 +196,7 @@ function unescapeICS(str) { async function sync() { const creds = getCredentials(); if (!creds) { - throw new Error('[Apple] Keine Credentials konfiguriert (weder in DB noch in .env).'); + throw new Error('[Apple] No credentials configured (neither in DB nor in .env).'); } // tsdav ist eine optionale Abhängigkeit - dynamischer Import für graceful degradation @@ -211,14 +211,14 @@ async function sync() { const calendars = await client.fetchCalendars(); if (!calendars.length) { - log.warn('Keine Kalender gefunden.'); + log.warn('No calendars found.'); return; } // created_by: ersten existierenden User verwenden (nicht hardcoded ID 1) const owner = db.get().prepare('SELECT id FROM users ORDER BY id ASC LIMIT 1').get(); if (!owner) { - log.warn('Kein User in der Datenbank - Sync übersprungen.'); + log.warn('No user in database - sync skipped.'); return; } const createdBy = owner.id; @@ -233,7 +233,7 @@ async function sync() { try { calObjects = await client.fetchCalendarObjects({ calendar: cal }); } catch (err) { - log.warn(`Kalender "${cal.displayName || '(unbenannt)'}" nicht abrufbar: ${err.message}`); + log.warn(`Calendar "${cal.displayName || '(unnamed)'}" is not accessible: ${err.message}`); continue; } @@ -277,7 +277,7 @@ async function sync() { ); } } catch (err) { - log.error(`Upsert-Fehler für UID ${ev.uid}:`, err.message); + log.error(`Upsert error for UID ${ev.uid}:`, err.message); } } } @@ -308,12 +308,12 @@ async function sync() { UPDATE calendar_events SET external_calendar_id = ?, external_source = 'apple' WHERE id = ? `).run(uid, event.id); } catch (err) { - log.error(`Outbound-Fehler für Event ${event.id}:`, err.message); + log.error(`Outbound error for event ${event.id}:`, err.message); } } cfgSet('apple_last_sync', new Date().toISOString()); - log.info(`Sync abgeschlossen - ${totalObjects} Objekte aus ${syncCalendars.length} Kalendern inbound, ${localEvents.length} lokal → iCloud.`); + log.info(`Sync completed - ${totalObjects} objects from ${syncCalendars.length} calendars inbound, ${localEvents.length} local → iCloud.`); } export { sync, getStatus, saveCredentials, clearCredentials, testConnection }; diff --git a/server/services/google-calendar.js b/server/services/google-calendar.js index 1f9d2a7..662b9f0 100644 --- a/server/services/google-calendar.js +++ b/server/services/google-calendar.js @@ -42,7 +42,7 @@ function createClient() { const redirectUri = process.env.GOOGLE_REDIRECT_URI; if (!clientId || !clientSecret || !redirectUri) { - throw new Error('[Google] GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET und GOOGLE_REDIRECT_URI müssen gesetzt sein.'); + throw new Error('[Google] GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_REDIRECT_URI must be set.'); } return new google.auth.OAuth2(clientId, clientSecret, redirectUri); @@ -79,7 +79,7 @@ function loadAuthorizedClient() { const refreshToken = cfgGet('google_refresh_token'); if (!accessToken || !refreshToken) { - throw new Error('[Google] Nicht konfiguriert - zuerst OAuth durchführen.'); + throw new Error('[Google] Not configured - complete OAuth first.'); } const client = createClient(); @@ -133,14 +133,14 @@ async function handleCallback(code) { const { tokens } = await client.getToken(code); if (!tokens.refresh_token) { - throw new Error('[Google] Kein Refresh Token erhalten. Bitte Zugriff in Google-Konto widerrufen und erneut verbinden.'); + throw new Error('[Google] No refresh token received. Revoke access in your Google account and connect again.'); } cfgSet('google_access_token', tokens.access_token); cfgSet('google_refresh_token', tokens.refresh_token); if (tokens.expiry_date) cfgSet('google_token_expiry', String(tokens.expiry_date)); - log.info('OAuth erfolgreich - Tokens gespeichert.'); + log.info('OAuth successful - tokens saved.'); } /** @@ -160,7 +160,7 @@ function getStatus() { function disconnect() { ['google_access_token', 'google_refresh_token', 'google_token_expiry', 'google_sync_token', 'google_last_sync'].forEach(cfgDel); - log.info('Verbindung getrennt.'); + log.info('Disconnected.'); } /** @@ -181,7 +181,7 @@ async function sync() { const calName = meta.data.summary || 'Google Calendar'; calRefId = upsertExternalCalendar('google', 'primary', calName, calColor); } catch (err) { - log.warn('Kalender-Metadaten nicht abrufbar:', err.message); + log.warn('Calendar metadata is not accessible:', err.message); } // -------------------------------------------------------- @@ -214,7 +214,7 @@ async function sync() { } catch (err) { if (err.code === 410) { // syncToken abgelaufen → vollständiger Resync - log.warn('syncToken ungültig - vollständiger Resync.'); + log.warn('syncToken invalid - full resync.'); cfgDel('google_sync_token'); syncToken = null; continue; @@ -250,12 +250,12 @@ async function sync() { UPDATE calendar_events SET external_calendar_id = ?, external_source = 'google' WHERE id = ? `).run(created.data.id, event.id); } catch (err) { - log.error(`Outbound-Fehler für Event ${event.id}:`, err.message); + log.error(`Outbound error for event ${event.id}:`, err.message); } } cfgSet('google_last_sync', new Date().toISOString()); - log.info(`Sync abgeschlossen - ${localEvents.length} lokal → Google, Inbound via syncToken.`); + log.info(`Sync completed - ${localEvents.length} local → Google, inbound via syncToken.`); } // -------------------------------------------------------- @@ -306,7 +306,7 @@ function upsertGoogleEvents(items, calRefId = null, calColor = GOOGLE_COLOR) { try { insertOrUpdate(item); } catch (err) { - log.error(`Upsert-Fehler für Event ${item.id}:`, err.message); + log.error(`Upsert error for event ${item.id}:`, err.message); } } } diff --git a/server/services/ics-subscription.js b/server/services/ics-subscription.js index e670977..e2eb127 100644 --- a/server/services/ics-subscription.js +++ b/server/services/ics-subscription.js @@ -27,7 +27,7 @@ const syncingNow = new Set(); function normalizeUrl(raw) { const url = new URL(raw.replace(/^webcal:\/\//i, 'https://')); - if (url.protocol !== 'https:') throw new Error('Nur https:// und webcal:// URLs sind erlaubt.'); + if (url.protocol !== 'https:') throw new Error('Only https:// and webcal:// URLs are allowed.'); return url.href; } @@ -37,7 +37,7 @@ async function checkSSRF(urlStr) { const v6 = await dns.resolve6(hostname).catch(() => []); for (const addr of [...v4, ...v6]) { if (PRIVATE_RANGES.some((re) => re.test(addr))) { - throw new Error(`URL löst auf eine private IP-Adresse auf: ${addr}`); + throw new Error(`URL resolves to a private IP address: ${addr}`); } } } @@ -61,12 +61,12 @@ async function fetchAndParse(urlRaw, etag, lastModified) { if (!res.ok) throw new Error(`HTTP ${res.status}`); const cl = parseInt(res.headers.get('content-length') || '0', 10); - if (cl > MAX_RESPONSE_BYTES) throw new Error('ICS-Datei überschreitet 10 MB Limit.'); + if (cl > MAX_RESPONSE_BYTES) throw new Error('ICS file exceeds the 10 MB limit.'); let body = '', received = 0; for await (const chunk of res.body) { received += chunk.length; - if (received > MAX_RESPONSE_BYTES) throw new Error('ICS-Datei überschreitet 10 MB Limit.'); + if (received > MAX_RESPONSE_BYTES) throw new Error('ICS file exceeds the 10 MB limit.'); body += chunk.toString(); } @@ -87,7 +87,7 @@ function syncWindow() { async function syncOne(sub) { if (syncingNow.has(sub.id)) { - log.info(`Abonnement ${sub.id} wird bereits synchronisiert - übersprungen.`); + log.info(`Subscription ${sub.id} is already syncing - skipped.`); return; } syncingNow.add(sub.id); @@ -95,7 +95,7 @@ async function syncOne(sub) { let result; try { result = await fetchAndParse(sub.url, sub.etag, sub.last_modified); } catch (err) { - log.warn(`Abonnement ${sub.id} (${sub.name}): Fetch fehlgeschlagen - ${err.message}`); + log.warn(`Subscription ${sub.id} (${sub.name}): fetch failed - ${err.message}`); return; } @@ -109,7 +109,7 @@ async function syncOne(sub) { const { windowStart, windowEnd } = syncWindow(); const owner = db.get().prepare('SELECT id FROM users ORDER BY id ASC LIMIT 1').get(); const createdBy = sub.created_by ?? owner?.id; - if (!createdBy) { log.warn('Kein User gefunden.'); return; } + if (!createdBy) { log.warn('No user found.'); return; } const flatEvents = []; for (const ev of events) { @@ -157,7 +157,7 @@ async function syncOne(sub) { .run(new Date().toISOString(), newEtag, newLastModified, sub.id); })(); - log.info(`Abonnement ${sub.id} (${sub.name}): ${flatEvents.length} Events synchronisiert.`); + log.info(`Subscription ${sub.id} (${sub.name}): ${flatEvents.length} events synced.`); } finally { syncingNow.delete(sub.id); } } @@ -167,7 +167,7 @@ async function sync(subscriptionId) { : db.get().prepare('SELECT * FROM ics_subscriptions').all(); for (const sub of subs) { try { await syncOne(sub); } - catch (err) { log.error(`Sync Abonnement ${sub.id} fehlgeschlagen: ${err.message}`); } + catch (err) { log.error(`Subscription ${sub.id} sync failed: ${err.message}`); } } } @@ -192,7 +192,7 @@ async function create(userId, { name, url, color, shared }) { function update(userId, subId, fields, isAdmin) { const sub = db.get().prepare('SELECT * FROM ics_subscriptions WHERE id = ?').get(subId); if (!sub) return null; - if (!isAdmin && sub.created_by !== userId) throw new Error('Nicht autorisiert.'); + if (!isAdmin && sub.created_by !== userId) throw new Error('Not authorized.'); const name = fields.name !== undefined ? fields.name : sub.name; const color = fields.color !== undefined ? fields.color : sub.color; const shared = fields.shared !== undefined ? (fields.shared ? 1 : 0) : sub.shared; @@ -204,7 +204,7 @@ function update(userId, subId, fields, isAdmin) { function remove(userId, subId, isAdmin) { const sub = db.get().prepare('SELECT * FROM ics_subscriptions WHERE id = ?').get(subId); if (!sub) return false; - if (!isAdmin && sub.created_by !== userId) throw new Error('Nicht autorisiert.'); + if (!isAdmin && sub.created_by !== userId) throw new Error('Not authorized.'); db.get().prepare('DELETE FROM ics_subscriptions WHERE id = ?').run(subId); return true; } diff --git a/setup.js b/setup.js index 28b59bb..d300f86 100644 --- a/setup.js +++ b/setup.js @@ -69,45 +69,45 @@ async function main() { .get(); if (existingAdmin) { - console.log('ℹ Es existiert bereits ein Admin-Account.\n'); - const proceed = await prompt('Trotzdem einen weiteren Admin anlegen? (j/N): '); - if (proceed.toLowerCase() !== 'j') { - console.log('Setup abgebrochen.'); + console.log('ℹ An admin account already exists.\n'); + const proceed = await prompt('Create another admin anyway? (y/N): '); + if (proceed.toLowerCase() !== 'y') { + console.log('Setup cancelled.'); rl.close(); process.exit(0); } } - console.log('Admin-Account anlegen:\n'); + console.log('Create admin account:\n'); - const username = (await prompt('Benutzername: ')).trim(); + const username = (await prompt('Username: ')).trim(); if (!username || username.length < 3) { - console.error('Fehler: Benutzername muss mindestens 3 Zeichen lang sein.'); + console.error('Error: username must be at least 3 characters long.'); process.exit(1); } - const displayName = (await prompt('Anzeigename (z.B. "Max Mustermann"): ')).trim(); + const displayName = (await prompt('Display name (e.g. "Max Mustermann"): ')).trim(); if (!displayName) { - console.error('Fehler: Anzeigename darf nicht leer sein.'); + console.error('Error: display name must not be empty.'); process.exit(1); } - const password = await promptPassword('Passwort: '); + const password = await promptPassword('Password: '); if (password.length < 8) { - console.error('Fehler: Passwort muss mindestens 8 Zeichen lang sein.'); + console.error('Error: password must be at least 8 characters long.'); process.exit(1); } - const passwordConfirm = await promptPassword('Passwort bestätigen: '); + const passwordConfirm = await promptPassword('Confirm password: '); if (password !== passwordConfirm) { - console.error('Fehler: Passwörter stimmen nicht überein.'); + console.error('Error: passwords do not match.'); process.exit(1); } const avatarColors = ['#007AFF', '#34C759', '#FF9500', '#FF3B30', '#AF52DE', '#FF2D55']; const avatarColor = avatarColors[Math.floor(Math.random() * avatarColors.length)]; - console.log('\nAccount wird erstellt …'); + console.log('\nCreating account …'); const hash = await bcrypt.hash(password, 12); @@ -122,23 +122,23 @@ async function main() { const port = process.env.PORT || 3000; const host = getLocalIP(); - console.log(`\n✅ Admin-Account erfolgreich erstellt!`); + console.log(`\n✅ Admin account created successfully!`); console.log(`${'─'.repeat(40)}`); - console.log(` Benutzername: ${username}`); - console.log(` Anzeigename: ${displayName}`); - console.log(` Rolle: Admin`); + console.log(` Username: ${username}`); + console.log(` Display name: ${displayName}`); + console.log(` Role: Admin`); console.log(`${'─'.repeat(40)}`); - console.log(`\n🌐 Oikos ist erreichbar unter:\n`); - console.log(` Lokal: http://localhost:${port}`); + console.log(`\n🌐 Oikos is available at:\n`); + console.log(` Local: http://localhost:${port}`); if (host) { - console.log(` Netzwerk: http://${host}:${port}`); + console.log(` Network: http://${host}:${port}`); } - console.log(`\n Melde dich mit deinem neuen Account an. Viel Spaß!\n`); + console.log(`\n Sign in with your new account.\n`); } catch (err) { if (err.message?.includes('UNIQUE constraint')) { - console.error(`\nFehler: Benutzername "${username}" ist bereits vergeben.`); + console.error(`\nError: username "${username}" is already taken.`); } else { - console.error('\nFehler beim Erstellen:', err.message); + console.error('\nCreation error:', err.message); } process.exit(1); } @@ -148,6 +148,6 @@ async function main() { } main().catch((err) => { - console.error('Unerwarteter Fehler:', err.message); + console.error('Unexpected error:', err.message); process.exit(1); }); diff --git a/test-api.js b/test-api.js index 776f6da..206a5f9 100644 --- a/test-api.js +++ b/test-api.js @@ -43,7 +43,7 @@ function setup() { test('auth.login: 401 feuert kein auth:expired', async () => { setup(); - _mockFetch = () => mockResponse(401, { error: 'Ungültige Anmeldedaten.', code: 401 }); + _mockFetch = () => mockResponse(401, { error: 'Invalid credentials.', code: 401 }); await assert.rejects( () => auth.login('user', 'wrong'), @@ -60,7 +60,7 @@ test('auth.login: 401 feuert kein auth:expired', async () => { test('auth.login: 401 wirft ApiError mit status 401', async () => { setup(); - _mockFetch = () => mockResponse(401, { error: 'Ungültige Anmeldedaten.', code: 401 }); + _mockFetch = () => mockResponse(401, { error: 'Invalid credentials.', code: 401 }); let thrownErr; try { @@ -77,7 +77,7 @@ test('auth.login: 401 wirft ApiError mit status 401', async () => { test('api.get: 401 auf geschütztem Endpunkt feuert auth:expired', async () => { setup(); - _mockFetch = () => mockResponse(401, { error: 'Nicht authentifiziert.', code: 401 }); + _mockFetch = () => mockResponse(401, { error: 'Not authenticated.', code: 401 }); await assert.rejects(() => api.get('/tasks')); @@ -87,7 +87,7 @@ test('api.get: 401 auf geschütztem Endpunkt feuert auth:expired', async () => { test('api.post: 401 auf Logout-Endpunkt feuert auth:expired', async () => { setup(); - _mockFetch = () => mockResponse(401, { error: 'Nicht authentifiziert.', code: 401 }); + _mockFetch = () => mockResponse(401, { error: 'Not authenticated.', code: 401 }); await assert.rejects(() => api.post('/auth/logout', {})); 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');