From 3bec77db3bddab7ce3b1277ff341415af1af66e0 Mon Sep 17 00:00:00 2001 From: Ulas Date: Tue, 31 Mar 2026 21:41:20 +0200 Subject: [PATCH 01/17] feat: add i18n module (public/i18n.js) --- public/i18n.js | 97 +++++++++++++++++++++++++++++++++++++++++ public/locales/.gitkeep | 0 2 files changed, 97 insertions(+) create mode 100644 public/i18n.js create mode 100644 public/locales/.gitkeep diff --git a/public/i18n.js b/public/i18n.js new file mode 100644 index 0000000..a37f571 --- /dev/null +++ b/public/i18n.js @@ -0,0 +1,97 @@ +/** + * i18n — Internationalisierung / Übersetzungsmodul + * Bietet t(), initI18n(), setLocale(), getLocale(), getSupportedLocales(), + * formatDate(), formatTime() für die gesamte App. + * Dependencies: none (vanilla JS, Fetch API, Intl API) + */ + +const SUPPORTED_LOCALES = ['de', 'en']; +const DEFAULT_LOCALE = 'de'; +const STORAGE_KEY = 'oikos-locale'; + +let currentLocale = DEFAULT_LOCALE; +let translations = {}; +let fallbackTranslations = {}; + +/** Resolve locale: manual override > navigator.language > default */ +function resolveLocale() { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored && SUPPORTED_LOCALES.includes(stored)) return stored; + + const browserLocales = navigator.languages || [navigator.language]; + for (const tag of browserLocales) { + const base = tag.split('-')[0].toLowerCase(); + if (SUPPORTED_LOCALES.includes(base)) return base; + } + return DEFAULT_LOCALE; +} + +/** Lade eine Locale-JSON-Datei */ +async function loadLocale(locale) { + const resp = await fetch(`/locales/${locale}.json`); + if (!resp.ok) throw new Error(`Failed to load locale: ${locale}`); + return resp.json(); +} + +/** Initialisierung — einmal beim App-Start aufrufen */ +export async function initI18n() { + currentLocale = resolveLocale(); + fallbackTranslations = await loadLocale(DEFAULT_LOCALE); + if (currentLocale !== DEFAULT_LOCALE) { + translations = await loadLocale(currentLocale); + } else { + translations = fallbackTranslations; + } + document.documentElement.lang = currentLocale; +} + +/** Sprache wechseln — löst 'locale-changed' Event aus */ +export async function setLocale(locale) { + if (!SUPPORTED_LOCALES.includes(locale)) return; + localStorage.setItem(STORAGE_KEY, locale); + currentLocale = locale; + if (locale === DEFAULT_LOCALE) { + translations = fallbackTranslations; + } else { + translations = await loadLocale(locale); + } + document.documentElement.lang = locale; + window.dispatchEvent(new CustomEvent('locale-changed', { detail: { locale } })); +} + +/** Übersetzungsfunktion mit Platzhalter-Unterstützung {{variable}} */ +export function t(key, params = {}) { + let str = translations[key] ?? fallbackTranslations[key] ?? key; + for (const [k, v] of Object.entries(params)) { + str = str.replaceAll(`{{${k}}}`, String(v)); + } + return str; +} + +/** Aktuelle Locale abfragen */ +export function getLocale() { + return currentLocale; +} + +/** Liste der unterstützten Locales */ +export function getSupportedLocales() { + return [...SUPPORTED_LOCALES]; +} + +/** Datum locale-aware formatieren */ +export function formatDate(date) { + return new Intl.DateTimeFormat(currentLocale, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }).format(date instanceof Date ? date : new Date(date)); +} + +/** Uhrzeit locale-aware formatieren */ +export function formatTime(date) { + return new Intl.DateTimeFormat(currentLocale, { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(date instanceof Date ? date : new Date(date)); +} diff --git a/public/locales/.gitkeep b/public/locales/.gitkeep new file mode 100644 index 0000000..e69de29 From 9bf8f2abbbf5cb6bb5ee92de33cc7c7dfc26e387 Mon Sep 17 00:00:00 2001 From: Ulas Date: Tue, 31 Mar 2026 21:43:55 +0200 Subject: [PATCH 02/17] fix: improve i18n robustness (null guards, race condition, error resilience) --- public/i18n.js | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/public/i18n.js b/public/i18n.js index a37f571..5c45e41 100644 --- a/public/i18n.js +++ b/public/i18n.js @@ -38,7 +38,12 @@ export async function initI18n() { currentLocale = resolveLocale(); fallbackTranslations = await loadLocale(DEFAULT_LOCALE); if (currentLocale !== DEFAULT_LOCALE) { - translations = await loadLocale(currentLocale); + try { + translations = await loadLocale(currentLocale); + } catch { + translations = fallbackTranslations; + currentLocale = DEFAULT_LOCALE; + } } else { translations = fallbackTranslations; } @@ -50,11 +55,11 @@ export async function setLocale(locale) { if (!SUPPORTED_LOCALES.includes(locale)) return; localStorage.setItem(STORAGE_KEY, locale); currentLocale = locale; - if (locale === DEFAULT_LOCALE) { - translations = fallbackTranslations; - } else { - translations = await loadLocale(locale); - } + const loaded = locale === DEFAULT_LOCALE + ? fallbackTranslations + : await loadLocale(locale); + if (currentLocale !== locale) return; + translations = loaded; document.documentElement.lang = locale; window.dispatchEvent(new CustomEvent('locale-changed', { detail: { locale } })); } @@ -80,18 +85,24 @@ export function getSupportedLocales() { /** Datum locale-aware formatieren */ export function formatDate(date) { + if (date == null) return ''; + const d = date instanceof Date ? date : new Date(date); + if (isNaN(d.getTime())) return ''; return new Intl.DateTimeFormat(currentLocale, { day: '2-digit', month: '2-digit', year: 'numeric', - }).format(date instanceof Date ? date : new Date(date)); + }).format(d); } /** Uhrzeit locale-aware formatieren */ export function formatTime(date) { + if (date == null) return ''; + const d = date instanceof Date ? date : new Date(date); + if (isNaN(d.getTime())) return ''; return new Intl.DateTimeFormat(currentLocale, { hour: '2-digit', minute: '2-digit', hour12: false, - }).format(date instanceof Date ? date : new Date(date)); + }).format(d); } From ad921e1637dc47a1c0b6ca00a872923e8cc340d5 Mon Sep 17 00:00:00 2001 From: Ulas Date: Tue, 31 Mar 2026 21:51:18 +0200 Subject: [PATCH 03/17] feat: add de.json and en.json locale files Extracts all German UI strings from public/pages/, public/components/, and public/router.js into 482 i18n keys organized by module prefix. English translations added for all keys. Co-Authored-By: Claude Sonnet 4.6 --- public/locales/de.json | 525 +++++++++++++++++++++++++++++++++++++++++ public/locales/en.json | 525 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1050 insertions(+) create mode 100644 public/locales/de.json create mode 100644 public/locales/en.json diff --git a/public/locales/de.json b/public/locales/de.json new file mode 100644 index 0000000..e517219 --- /dev/null +++ b/public/locales/de.json @@ -0,0 +1,525 @@ +{ + "common": { + "save": "Speichern", + "cancel": "Abbrechen", + "delete": "Löschen", + "edit": "Bearbeiten", + "close": "Schließen", + "create": "Erstellen", + "add": "Hinzufügen", + "back": "Zurück", + "next": "Weiter", + "loading": "Lade…", + "saving": "Wird gespeichert…", + "required": "Dieses Feld ist erforderlich.", + "error": "Fehler", + "allFieldsRequired": "Bitte alle Felder ausfüllen.", + "today": "Heute", + "tomorrow": "Morgen", + "skipToContent": "Zum Inhalt springen", + "reload": "Neu laden", + "errorOccurred": "Etwas ist schiefgelaufen.", + "unexpectedError": "Ein unerwarteter Fehler ist aufgetreten.", + "errorGeneric": "Ein Fehler ist aufgetreten.", + "updateAvailable": "Update verfügbar — Seite neu laden für die neueste Version.", + "titleRequired": "Titel ist erforderlich", + "nameRequired": "Name ist erforderlich", + "contentRequired": "Inhalt ist erforderlich", + "all": "Alle" + }, + + "nav": { + "dashboard": "Übersicht", + "tasks": "Aufgaben", + "calendar": "Kalender", + "meals": "Essen", + "shopping": "Einkauf", + "notes": "Pinnwand", + "contacts": "Kontakte", + "budget": "Budget", + "settings": "Einstellungen", + "main": "Hauptnavigation", + "navigation": "Navigation", + "quickActions": "Schnellaktionen" + }, + + "dashboard": { + "title": "Übersicht", + "greetingMorning": "Guten Morgen, {{name}}", + "greetingDay": "Guten Tag, {{name}}", + "greetingEvening": "Guten Abend, {{name}}", + "allDone": "Alles erledigt", + "noEvents": "Keine Termine", + "noPinnedNotes": "Keine angepinnten Notizen", + "todayMeals": "Heute essen", + "allLink": "Alle", + "weekLink": "Woche", + "urgentTasksChip": "{{count}} dring. Aufgabe", + "urgentTasksChipPlural": "{{count}} dring. Aufgaben", + "eventsChip": "{{count}} Termin heute", + "eventsChipPlural": "{{count}} Termine heute", + "todayMealChip": "Heute: {{title}}", + "loadError": "Dashboard konnte nicht vollständig geladen werden.", + "weatherRefresh": "Wetter aktualisieren", + "weatherRefreshTitle": "Aktualisieren", + "weatherFeelsLike": "Gefühlt {{temp}}° · {{humidity}}% · Wind {{wind}} km/h", + "fabTaskLabel": "Aufgabe hinzufügen", + "fabCalendarLabel": "Termin hinzufügen", + "fabShoppingLabel": "Einkauf hinzufügen", + "fabNoteLabel": "Notiz hinzufügen", + "fabTask": "Aufgabe", + "fabCalendar": "Termin", + "fabShopping": "Einkauf", + "fabNote": "Notiz", + "overdue": "Überfällig", + "dueSoon": "Heute fällig", + "dueTomorrow": "Morgen fällig", + "allDay": "Ganztägig" + }, + + "tasks": { + "title": "Aufgaben", + "newTask": "Neue Aufgabe", + "editTask": "Aufgabe bearbeiten", + "emptyTitle": "Keine Aufgaben — alles erledigt?", + "emptyDescription": "Neue Aufgaben über den + Button erstellen.", + "titleLabel": "Titel *", + "titlePlaceholder": "Was muss erledigt werden?", + "descriptionLabel": "Notiz", + "descriptionPlaceholder": "Optionale Details…", + "priorityLabel": "Priorität", + "categoryLabel": "Kategorie", + "dueDateLabel": "Fälligkeit", + "dueTimeLabel": "Uhrzeit", + "assignedLabel": "Zugewiesen an", + "assignedNobody": "— Niemand —", + "statusLabel": "Status", + "priorityUrgent": "Dringend", + "priorityHigh": "Hoch", + "priorityMedium": "Mittel", + "priorityLow": "Niedrig", + "statusOpen": "Offen", + "statusInProgress": "In Bearbeitung", + "statusDone": "Erledigt", + "categoryHousehold": "Haushalt", + "categorySchool": "Schule", + "categoryShopping": "Einkauf", + "categoryRepair": "Reparatur", + "categoryHealth": "Gesundheit", + "categoryFinance": "Finanzen", + "categoryLeisure": "Freizeit", + "categoryMisc": "Sonstiges", + "overdue": "Überfällig", + "overdueDay": "{{count}}d überfällig", + "dueToday": "Heute fällig", + "dueTomorrow": "Morgen fällig", + "groupOverdue": "Überfällig", + "groupToday": "Heute", + "groupThisWeek": "Diese Woche", + "groupNextWeek": "Nächste Woche", + "groupLater": "Später", + "groupNoDate": "Kein Datum", + "markDone": "{{title}} als erledigt markieren", + "editButton": "Aufgabe bearbeiten", + "swipeOpen": "Öffnen", + "swipeDone": "Erledigt", + "swipeEdit": "Bearbeiten", + "subtaskAdd": "+ Teilaufgabe hinzufügen", + "subtaskToggle": "Teilaufgaben anzeigen", + "subtaskMarkDone": "{{title}} als erledigt markieren", + "deleteConfirm": "Aufgabe und alle Teilaufgaben löschen?", + "savedToast": "Aufgabe gespeichert.", + "createdToast": "Aufgabe erstellt.", + "deletedToast": "Aufgabe gelöscht.", + "loadError": "Aufgabe konnte nicht geladen werden.", + "subtaskPrompt": "Teilaufgabe:", + "kanbanOpen": "Offen", + "kanbanInProgress": "In Bearbeitung", + "kanbanDone": "Erledigt" + }, + + "shopping": { + "title": "Einkauf", + "noLists": "Keine Listen", + "noListsDescription": "Erstelle eine Liste mit dem + Button.", + "emptyList": "Die Liste ist leer", + "emptyListDescription": "Artikel über das Eingabefeld oben hinzufügen.", + "newListPrompt": "Name der neuen Liste:", + "newListButton": "Neue Liste erstellen", + "renameListPrompt": "Neuer Listen-Name:", + "deleteListConfirm": "Liste \"{{name}}\" und alle Artikel löschen?", + "deletedListToast": "Liste gelöscht.", + "itemsRemovedToast": "{{count}} Artikel entfernt.", + "clearChecked": "Abgehakt löschen ({{count}})", + "itemNamePlaceholder": "Artikel hinzufügen…", + "itemQtyPlaceholder": "Menge", + "itemNameLabel": "Artikelname", + "itemQtyLabel": "Menge", + "categoryLabel": "Kategorie", + "addItemLabel": "Artikel hinzufügen", + "renameListLabel": "Liste umbenennen", + "deleteListLabel": "Liste löschen", + "swipeBack": "Zurück", + "swipeCheck": "Abhaken", + "swipeDelete": "Löschen", + "markDoneLabel": "{{name}} abhaken", + "markUndoneLabel": "{{name}} als nicht erledigt markieren", + "deleteItemLabel": "{{name}} löschen", + "listsLoadError": "Listen konnten nicht geladen werden.", + "itemsLoadError": "Artikel konnten nicht geladen werden.", + "catFruitVeg": "Obst & Gemüse", + "catBakery": "Backwaren", + "catDairy": "Milchprodukte", + "catMeatFish": "Fleisch & Fisch", + "catFrozen": "Tiefkühl", + "catDrinks": "Getränke", + "catHousehold": "Haushalt", + "catDrugstore": "Drogerie", + "catMisc": "Sonstiges" + }, + + "meals": { + "title": "Essensplan", + "noMealPlanned": "Kein Essen geplant", + "addMeal": "{{type}} hinzufügen", + "editMeal": "Mahlzeit bearbeiten", + "addMealTitle": "Mahlzeit hinzufügen", + "deleteMeal": "Mahlzeit löschen", + "transferToShoppingList": "Zutaten auf Einkaufsliste", + "today": "Heute", + "prevWeek": "Vorherige Woche", + "nextWeek": "Nächste Woche", + "loadError": "Essensplan konnte nicht geladen werden.", + "typeBreakfast": "Frühstück", + "typeLunch": "Mittagessen", + "typeDinner": "Abendessen", + "typeSnack": "Snack", + "dayMo": "Mo", + "dayDi": "Di", + "dayMi": "Mi", + "dayDo": "Do", + "dayFr": "Fr", + "daySa": "Sa", + "daySo": "So", + "dateLabel": "Datum", + "mealTypeLabel": "Mahlzeit", + "titleLabel": "Titel *", + "titlePlaceholder": "z.B. Spaghetti Bolognese", + "notesLabel": "Notizen", + "notesPlaceholder": "Optional…", + "ingredientsLabel": "Zutaten", + "addIngredient": "Zutat hinzufügen", + "ingredientNamePlaceholder": "Zutat", + "ingredientQtyPlaceholder": "Menge", + "removeIngredient": "Zutat entfernen", + "transferLabel": "Zutaten auf Einkaufsliste übertragen", + "transferNow": "Jetzt übertragen", + "noShoppingLists": "Keine Einkaufslisten vorhanden", + "transferSuccess": "{{count}} Zutat übertragen", + "transferSuccessPlural": "{{count}} Zutaten übertragen", + "transferAlreadyDone": "Alle Zutaten bereits übertragen", + "ingredientCount": "{{count}} Zutat", + "ingredientCountPlural": "{{count}} Zutaten", + "titleRequired": "Titel ist erforderlich", + "loadingIndicator": "Lade…" + }, + + "calendar": { + "title": "Kalender", + "newEvent": "Neuer Termin", + "editEvent": "Termin bearbeiten", + "addEvent": "Termin hinzufügen", + "deleteEvent": "Termin löschen", + "noEvents": "Keine Termine im gewählten Zeitraum.", + "today": "Heute", + "back": "Zurück", + "forward": "Weiter", + "viewMonth": "Monat", + "viewWeek": "Woche", + "viewDay": "Tag", + "viewAgenda": "Agenda", + "allDay": "Ganztägig", + "allDayShort": "ganztg.", + "moreEvents": "+{{count}} weitere", + "weekNumberLabel": "KW {{week}} · {{month}} {{year}}", + "agendaFrom": "Ab {{date}}", + "titleLabel": "Titel *", + "titlePlaceholder": "z.B. Zahnarzt", + "allDayToggle": "Ganztägig", + "startDateLabel": "Startdatum", + "startTimeLabel": "Startzeit", + "endDateLabel": "Enddatum", + "endTimeLabel": "Endzeit", + "fromLabel": "Von", + "toLabel": "Bis", + "locationLabel": "Ort", + "locationPlaceholder": "Optional", + "assignedLabel": "Zugewiesen an", + "assignedNobody": "— Niemand —", + "colorLabel": "Farbe", + "descriptionLabel": "Beschreibung", + "descriptionPlaceholder": "Optional…", + "popupEdit": "Bearbeiten", + "deleteConfirm": "\"{{title}}\" wirklich löschen?", + "createdToast": "Termin erstellt", + "savedToast": "Termin gespeichert", + "deletedToast": "Termin gelöscht", + "loadError": "Termine konnten nicht geladen werden.", + "saveError": "Fehler beim Speichern", + "deleteError": "Fehler beim Löschen", + "titleRequired": "Titel ist erforderlich", + "monthJanuary": "Januar", + "monthFebruary": "Februar", + "monthMarch": "März", + "monthApril": "April", + "monthMay": "Mai", + "monthJune": "Juni", + "monthJuly": "Juli", + "monthAugust": "August", + "monthSeptember": "September", + "monthOctober": "Oktober", + "monthNovember": "November", + "monthDecember": "Dezember", + "dayShortSunday": "So", + "dayShortMonday": "Mo", + "dayShortTuesday": "Di", + "dayShortWednesday": "Mi", + "dayShortThursday": "Do", + "dayShortFriday": "Fr", + "dayShortSaturday": "Sa", + "dayLongSunday": "Sonntag", + "dayLongMonday": "Montag", + "dayLongTuesday": "Dienstag", + "dayLongWednesday": "Mittwoch", + "dayLongThursday": "Donnerstag", + "dayLongFriday": "Freitag", + "dayLongSaturday": "Samstag" + }, + + "notes": { + "title": "Pinnwand", + "newNote": "Neue Notiz", + "editNote": "Notiz bearbeiten", + "addNoteLabel": "Neue Notiz", + "searchPlaceholder": "Notizen durchsuchen…", + "emptyTitle": "Noch keine Notizen", + "emptyDescription": "Neue Notiz über den + Button erstellen.", + "noResultsTitle": "Keine Treffer", + "noResultsDescription": "Keine Notiz enthält \"{{query}}\".", + "titleLabel": "Titel (optional)", + "titlePlaceholder": "Kein Titel", + "contentLabel": "Inhalt", + "contentMarkdownHint": "(Markdown-Formatierung möglich)", + "contentPlaceholder": "Notiz eingeben…", + "colorLabel": "Farbe", + "pinnedLabel": "Anpinnen (erscheint auf Dashboard)", + "pinAction": "Anpinnen", + "unpinAction": "Anpinnen aufheben", + "deleteLabel": "Notiz löschen", + "deleteConfirm": "Notiz wirklich löschen?", + "createdToast": "Notiz erstellt", + "savedToast": "Notiz gespeichert", + "deletedToast": "Notiz gelöscht", + "loadError": "Notizen konnten nicht geladen werden.", + "formatBold": "Fett (Strg+B)", + "formatItalic": "Kursiv (Strg+I)", + "formatUnderline": "Unterstrichen (Strg+U)", + "formatStrikethrough": "Durchgestrichen", + "formatHeading": "Überschrift", + "formatList": "Aufzählung", + "formatOrderedList": "Nummerierte Liste", + "formatChecklist": "Checkliste", + "formatLink": "Link", + "formatCode": "Code", + "formatQuote": "Zitat", + "formatDivider": "Trennlinie" + }, + + "contacts": { + "title": "Kontakte", + "newContact": "Neuer Kontakt", + "editContact": "Kontakt bearbeiten", + "addButton": "Neu", + "newContactLabel": "Neuer Kontakt", + "searchPlaceholder": "Name, Telefon oder E-Mail suchen…", + "importButton": "Import", + "importLabel": "Kontakt aus vCard importieren", + "importTooltip": "vCard importieren", + "emptyTitle": "Noch keine Kontakte", + "emptyDescription": "Neue Kontakte über den + Button hinzufügen.", + "filterAll": "Alle", + "nameLabel": "Name *", + "namePlaceholder": "Vollständiger Name", + "categoryLabel": "Kategorie", + "phoneLabel": "Telefon", + "phonePlaceholder": "+49 …", + "emailLabel": "E-Mail", + "emailPlaceholder": "name@beispiel.de", + "addressLabel": "Adresse", + "addressPlaceholder": "Straße, PLZ Ort", + "notesLabel": "Notizen", + "notesPlaceholder": "Optional…", + "callLabel": "Anrufen", + "emailActionLabel": "E-Mail", + "mapsLabel": "In Maps öffnen", + "exportLabel": "Als vCard exportieren", + "exportTooltip": "vCard exportieren", + "deleteLabel": "Kontakt löschen", + "deleteConfirm": "Kontakt wirklich löschen?", + "deletePersonConfirm": "\"{{name}}\" wirklich löschen?", + "savedToast": "Kontakt gespeichert", + "updatedToast": "Kontakt aktualisiert", + "deletedToast": "Kontakt gelöscht", + "importedToast": "{{name}} importiert.", + "importError": "Import fehlgeschlagen: {{error}}", + "vcardNoName": "vCard enthält keinen Namen.", + "catDoctor": "Arzt", + "catSchool": "Schule/Kita", + "catAuthority": "Behörde", + "catInsurance": "Versicherung", + "catCraftsman": "Handwerker", + "catEmergency": "Notfall", + "catMisc": "Sonstiges" + }, + + "budget": { + "title": "Budget", + "newEntry": "Neuer Eintrag", + "editEntry": "Eintrag bearbeiten", + "addEntryLabel": "Eintrag hinzufügen", + "newEntryFabLabel": "Neuer Eintrag", + "currentMonth": "Aktuell", + "prevMonth": "Vorheriger Monat", + "nextMonth": "Nächster Monat", + "income": "Einnahmen", + "expenses": "Ausgaben", + "balance": "Saldo", + "byCategory": "Nach Kategorie", + "transactions": "Transaktionen", + "emptyTitle": "Keine Einträge diesen Monat", + "emptyDescription": "Budget-Einträge über den + Button hinzufügen.", + "csvExport": "CSV", + "typeExpense": "Ausgabe", + "typeIncome": "Einnahme", + "titleLabel": "Titel *", + "titlePlaceholder": "z.B. REWE Einkauf", + "amountLabel": "Betrag (€) *", + "amountPlaceholder": "0,00", + "categoryLabel": "Kategorie", + "dateLabel": "Datum *", + "recurringLabel": "Wiederkehrend", + "deleteLabel": "Eintrag löschen", + "deleteConfirm": "Eintrag wirklich löschen?", + "deletePersonConfirm": "\"{{title}}\" wirklich löschen?", + "addedToast": "Eintrag hinzugefügt", + "savedToast": "Eintrag gespeichert", + "deletedToast": "Eintrag gelöscht", + "loadError": "Budget konnte nicht geladen werden.", + "trendNeutral": "— wie {{month}}", + "validAmountRequired": "Gültigen Betrag eingeben", + "dateRequired": "Datum ist erforderlich", + "catFood": "Lebensmittel", + "catRent": "Miete", + "catInsurance": "Versicherung", + "catMobility": "Mobilität", + "catLeisure": "Freizeit", + "catClothing": "Kleidung", + "catHealth": "Gesundheit", + "catEducation": "Bildung", + "catMisc": "Sonstiges", + "loadingIndicator": "Lade…" + }, + + "settings": { + "title": "Einstellungen", + "sectionDesign": "Design", + "sectionAccount": "Mein Konto", + "sectionCalendarSync": "Kalender-Synchronisation", + "sectionFamily": "Familienmitglieder", + "cardAppearance": "Darstellung", + "themeSystem": "System", + "themeSysLabel": "System-Einstellung verwenden", + "themeLight": "Hell", + "themeLightLabel": "Helles Design", + "themeDark": "Dunkel", + "themeDarkLabel": "Dunkles Design", + "changePassword": "Passwort ändern", + "currentPasswordLabel": "Aktuelles Passwort", + "newPasswordLabel": "Neues Passwort", + "confirmPasswordLabel": "Neues Passwort bestätigen", + "savePassword": "Passwort speichern", + "passwordMismatch": "Passwörter stimmen nicht überein.", + "passwordSavedToast": "Passwort erfolgreich geändert.", + "googleCalendar": "Google Calendar", + "appleCalendar": "Apple Calendar (iCloud)", + "syncNow": "Jetzt synchronisieren", + "disconnect": "Verbindung trennen", + "connectGoogle": "Mit Google verbinden", + "connected": "Verbunden", + "connectedLastSync": "Verbunden · Zuletzt: {{date}}", + "notConnected": "Nicht verbunden", + "notConfigured": "Nicht konfiguriert (fehlende .env-Variablen)", + "configured": "Konfiguriert (via .env)", + "configuredLastSync": "Konfiguriert (via .env) · Zuletzt: {{date}}", + "syncSuccess": "{{provider}} synchronisiert.", + "disconnectedToast": "{{provider}} getrennt.", + "googleOnlyAdmin": "Nur Admin kann Google Calendar verbinden.", + "appleOnlyAdmin": "Nur Admin kann Apple Calendar verbinden.", + "caldavUrlLabel": "CalDAV-Server-URL", + "caldavUrlPlaceholder": "https://caldav.icloud.com", + "appleIdLabel": "Apple-ID (E-Mail)", + "applePasswordLabel": "App-spezifisches Passwort", + "applePasswordHint": "Passwort unter appleid.apple.com → Sicherheit erstellen.", + "appleConnectBtn": "Verbinden & testen", + "appleConnecting": "Verbinde…", + "appleConnectedToast": "Apple Calendar verbunden.", + "syncSuccessGoogle": "Kalender-Sync mit Google erfolgreich verbunden.", + "syncSuccessApple": "Kalender-Sync mit Apple erfolgreich verbunden.", + "syncErrorGoogle": "Verbindung mit Google fehlgeschlagen. Bitte erneut versuchen.", + "syncErrorApple": "Verbindung mit Apple fehlgeschlagen. Bitte erneut versuchen.", + "addMember": "+ Mitglied hinzufügen", + "newMemberTitle": "Neues Familienmitglied", + "usernameLabel": "Benutzername", + "displayNameLabel": "Anzeigename", + "memberPasswordLabel": "Passwort", + "colorLabel": "Farbe", + "roleLabel": "Rolle", + "roleMember": "Mitglied", + "roleAdmin": "Admin", + "createMember": "Erstellen", + "cancelAddMember": "Abbrechen", + "memberAddedToast": "{{name}} hinzugefügt.", + "deleteMemberConfirm": "{{name}} wirklich löschen?", + "memberDeletedToast": "{{name}} gelöscht.", + "deleteMemberLabel": "Löschen", + "logout": "Abmelden", + "synchronizing": "Synchronisiere…", + "googleDisconnectConfirm": "Google Calendar-Verbindung trennen?", + "appleDisconnectConfirm": "Apple Calendar-Verbindung trennen?" + }, + + "login": { + "tagline": "Familienplanung. Sicher. Datenschutzfreundlich. Open Source.", + "usernameLabel": "Benutzername", + "usernamePlaceholder": "benutzername", + "passwordLabel": "Passwort", + "passwordPlaceholder": "••••••••", + "loginButton": "Anmelden", + "loggingIn": "Wird angemeldet …", + "tooManyAttempts": "Zu viele Versuche. Bitte warte kurz.", + "invalidCredentials": "Ungültige Anmeldedaten." + }, + + "install": { + "title": "Oikos installieren", + "subtitle": "Zur App hinzufügen", + "iosTip1": "Tippe auf ", + "iosTip2": " \u2192 \"Zum Home-Bildschirm\"", + "installButton": "Installieren", + "dismissLabel": "Schließen" + }, + + "modal": { + "closeLabel": "Schließen" + } +} diff --git a/public/locales/en.json b/public/locales/en.json new file mode 100644 index 0000000..c6c2819 --- /dev/null +++ b/public/locales/en.json @@ -0,0 +1,525 @@ +{ + "common": { + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "close": "Close", + "create": "Create", + "add": "Add", + "back": "Back", + "next": "Next", + "loading": "Loading…", + "saving": "Saving…", + "required": "This field is required.", + "error": "Error", + "allFieldsRequired": "Please fill in all fields.", + "today": "Today", + "tomorrow": "Tomorrow", + "skipToContent": "Skip to content", + "reload": "Reload", + "errorOccurred": "Something went wrong.", + "unexpectedError": "An unexpected error occurred.", + "errorGeneric": "An error occurred.", + "updateAvailable": "Update available — reload the page to get the latest version.", + "titleRequired": "Title is required", + "nameRequired": "Name is required", + "contentRequired": "Content is required", + "all": "All" + }, + + "nav": { + "dashboard": "Overview", + "tasks": "Tasks", + "calendar": "Calendar", + "meals": "Meals", + "shopping": "Shopping", + "notes": "Board", + "contacts": "Contacts", + "budget": "Budget", + "settings": "Settings", + "main": "Main navigation", + "navigation": "Navigation", + "quickActions": "Quick actions" + }, + + "dashboard": { + "title": "Overview", + "greetingMorning": "Good morning, {{name}}", + "greetingDay": "Good afternoon, {{name}}", + "greetingEvening": "Good evening, {{name}}", + "allDone": "All done", + "noEvents": "No events", + "noPinnedNotes": "No pinned notes", + "todayMeals": "Today's meals", + "allLink": "All", + "weekLink": "Week", + "urgentTasksChip": "{{count}} urgent task", + "urgentTasksChipPlural": "{{count}} urgent tasks", + "eventsChip": "{{count}} event today", + "eventsChipPlural": "{{count}} events today", + "todayMealChip": "Today: {{title}}", + "loadError": "Dashboard could not be fully loaded.", + "weatherRefresh": "Refresh weather", + "weatherRefreshTitle": "Refresh", + "weatherFeelsLike": "Feels like {{temp}}° · {{humidity}}% · Wind {{wind}} km/h", + "fabTaskLabel": "Add task", + "fabCalendarLabel": "Add event", + "fabShoppingLabel": "Add shopping", + "fabNoteLabel": "Add note", + "fabTask": "Task", + "fabCalendar": "Event", + "fabShopping": "Shopping", + "fabNote": "Note", + "overdue": "Overdue", + "dueSoon": "Due today", + "dueTomorrow": "Due tomorrow", + "allDay": "All day" + }, + + "tasks": { + "title": "Tasks", + "newTask": "New Task", + "editTask": "Edit Task", + "emptyTitle": "No tasks — all done?", + "emptyDescription": "Create new tasks with the + button.", + "titleLabel": "Title *", + "titlePlaceholder": "What needs to be done?", + "descriptionLabel": "Note", + "descriptionPlaceholder": "Optional details…", + "priorityLabel": "Priority", + "categoryLabel": "Category", + "dueDateLabel": "Due date", + "dueTimeLabel": "Time", + "assignedLabel": "Assigned to", + "assignedNobody": "— Nobody —", + "statusLabel": "Status", + "priorityUrgent": "Urgent", + "priorityHigh": "High", + "priorityMedium": "Medium", + "priorityLow": "Low", + "statusOpen": "Open", + "statusInProgress": "In Progress", + "statusDone": "Done", + "categoryHousehold": "Household", + "categorySchool": "School", + "categoryShopping": "Shopping", + "categoryRepair": "Repair", + "categoryHealth": "Health", + "categoryFinance": "Finance", + "categoryLeisure": "Leisure", + "categoryMisc": "Miscellaneous", + "overdue": "Overdue", + "overdueDay": "{{count}}d overdue", + "dueToday": "Due today", + "dueTomorrow": "Due tomorrow", + "groupOverdue": "Overdue", + "groupToday": "Today", + "groupThisWeek": "This week", + "groupNextWeek": "Next week", + "groupLater": "Later", + "groupNoDate": "No date", + "markDone": "Mark {{title}} as done", + "editButton": "Edit task", + "swipeOpen": "Reopen", + "swipeDone": "Done", + "swipeEdit": "Edit", + "subtaskAdd": "+ Add subtask", + "subtaskToggle": "Show subtasks", + "subtaskMarkDone": "Mark {{title}} as done", + "deleteConfirm": "Delete task and all subtasks?", + "savedToast": "Task saved.", + "createdToast": "Task created.", + "deletedToast": "Task deleted.", + "loadError": "Task could not be loaded.", + "subtaskPrompt": "Subtask:", + "kanbanOpen": "Open", + "kanbanInProgress": "In Progress", + "kanbanDone": "Done" + }, + + "shopping": { + "title": "Shopping", + "noLists": "No lists", + "noListsDescription": "Create a list with the + button.", + "emptyList": "The list is empty", + "emptyListDescription": "Add items using the input field above.", + "newListPrompt": "Name for the new list:", + "newListButton": "Create new list", + "renameListPrompt": "New list name:", + "deleteListConfirm": "Delete list \"{{name}}\" and all items?", + "deletedListToast": "List deleted.", + "itemsRemovedToast": "{{count}} items removed.", + "clearChecked": "Remove checked ({{count}})", + "itemNamePlaceholder": "Add item…", + "itemQtyPlaceholder": "Quantity", + "itemNameLabel": "Item name", + "itemQtyLabel": "Quantity", + "categoryLabel": "Category", + "addItemLabel": "Add item", + "renameListLabel": "Rename list", + "deleteListLabel": "Delete list", + "swipeBack": "Undo", + "swipeCheck": "Check off", + "swipeDelete": "Delete", + "markDoneLabel": "Check off {{name}}", + "markUndoneLabel": "Uncheck {{name}}", + "deleteItemLabel": "Delete {{name}}", + "listsLoadError": "Lists could not be loaded.", + "itemsLoadError": "Items could not be loaded.", + "catFruitVeg": "Fruit & Vegetables", + "catBakery": "Bakery", + "catDairy": "Dairy", + "catMeatFish": "Meat & Fish", + "catFrozen": "Frozen", + "catDrinks": "Drinks", + "catHousehold": "Household", + "catDrugstore": "Drugstore", + "catMisc": "Miscellaneous" + }, + + "meals": { + "title": "Meal Plan", + "noMealPlanned": "No meal planned", + "addMeal": "Add {{type}}", + "editMeal": "Edit meal", + "addMealTitle": "Add meal", + "deleteMeal": "Delete meal", + "transferToShoppingList": "Add ingredients to shopping list", + "today": "Today", + "prevWeek": "Previous week", + "nextWeek": "Next week", + "loadError": "Meal plan could not be loaded.", + "typeBreakfast": "Breakfast", + "typeLunch": "Lunch", + "typeDinner": "Dinner", + "typeSnack": "Snack", + "dayMo": "Mon", + "dayDi": "Tue", + "dayMi": "Wed", + "dayDo": "Thu", + "dayFr": "Fri", + "daySa": "Sat", + "daySo": "Sun", + "dateLabel": "Date", + "mealTypeLabel": "Meal", + "titleLabel": "Title *", + "titlePlaceholder": "e.g. Spaghetti Bolognese", + "notesLabel": "Notes", + "notesPlaceholder": "Optional…", + "ingredientsLabel": "Ingredients", + "addIngredient": "Add ingredient", + "ingredientNamePlaceholder": "Ingredient", + "ingredientQtyPlaceholder": "Quantity", + "removeIngredient": "Remove ingredient", + "transferLabel": "Transfer ingredients to shopping list", + "transferNow": "Transfer now", + "noShoppingLists": "No shopping lists available", + "transferSuccess": "{{count}} ingredient transferred", + "transferSuccessPlural": "{{count}} ingredients transferred", + "transferAlreadyDone": "All ingredients already transferred", + "ingredientCount": "{{count}} ingredient", + "ingredientCountPlural": "{{count}} ingredients", + "titleRequired": "Title is required", + "loadingIndicator": "Loading…" + }, + + "calendar": { + "title": "Calendar", + "newEvent": "New Event", + "editEvent": "Edit Event", + "addEvent": "Add event", + "deleteEvent": "Delete event", + "noEvents": "No events in the selected period.", + "today": "Today", + "back": "Back", + "forward": "Forward", + "viewMonth": "Month", + "viewWeek": "Week", + "viewDay": "Day", + "viewAgenda": "Agenda", + "allDay": "All day", + "allDayShort": "all day", + "moreEvents": "+{{count}} more", + "weekNumberLabel": "W{{week}} · {{month}} {{year}}", + "agendaFrom": "From {{date}}", + "titleLabel": "Title *", + "titlePlaceholder": "e.g. Dentist", + "allDayToggle": "All day", + "startDateLabel": "Start date", + "startTimeLabel": "Start time", + "endDateLabel": "End date", + "endTimeLabel": "End time", + "fromLabel": "From", + "toLabel": "To", + "locationLabel": "Location", + "locationPlaceholder": "Optional", + "assignedLabel": "Assigned to", + "assignedNobody": "— Nobody —", + "colorLabel": "Color", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Optional…", + "popupEdit": "Edit", + "deleteConfirm": "Really delete \"{{title}}\"?", + "createdToast": "Event created", + "savedToast": "Event saved", + "deletedToast": "Event deleted", + "loadError": "Events could not be loaded.", + "saveError": "Error saving", + "deleteError": "Error deleting", + "titleRequired": "Title is required", + "monthJanuary": "January", + "monthFebruary": "February", + "monthMarch": "March", + "monthApril": "April", + "monthMay": "May", + "monthJune": "June", + "monthJuly": "July", + "monthAugust": "August", + "monthSeptember": "September", + "monthOctober": "October", + "monthNovember": "November", + "monthDecember": "December", + "dayShortSunday": "Sun", + "dayShortMonday": "Mon", + "dayShortTuesday": "Tue", + "dayShortWednesday": "Wed", + "dayShortThursday": "Thu", + "dayShortFriday": "Fri", + "dayShortSaturday": "Sat", + "dayLongSunday": "Sunday", + "dayLongMonday": "Monday", + "dayLongTuesday": "Tuesday", + "dayLongWednesday": "Wednesday", + "dayLongThursday": "Thursday", + "dayLongFriday": "Friday", + "dayLongSaturday": "Saturday" + }, + + "notes": { + "title": "Board", + "newNote": "New Note", + "editNote": "Edit Note", + "addNoteLabel": "New Note", + "searchPlaceholder": "Search notes…", + "emptyTitle": "No notes yet", + "emptyDescription": "Create a new note with the + button.", + "noResultsTitle": "No results", + "noResultsDescription": "No note contains \"{{query}}\".", + "titleLabel": "Title (optional)", + "titlePlaceholder": "No title", + "contentLabel": "Content", + "contentMarkdownHint": "(Markdown formatting supported)", + "contentPlaceholder": "Enter note…", + "colorLabel": "Color", + "pinnedLabel": "Pin (appears on dashboard)", + "pinAction": "Pin", + "unpinAction": "Unpin", + "deleteLabel": "Delete note", + "deleteConfirm": "Really delete this note?", + "createdToast": "Note created", + "savedToast": "Note saved", + "deletedToast": "Note deleted", + "loadError": "Notes could not be loaded.", + "formatBold": "Bold (Ctrl+B)", + "formatItalic": "Italic (Ctrl+I)", + "formatUnderline": "Underline (Ctrl+U)", + "formatStrikethrough": "Strikethrough", + "formatHeading": "Heading", + "formatList": "Bullet list", + "formatOrderedList": "Numbered list", + "formatChecklist": "Checklist", + "formatLink": "Link", + "formatCode": "Code", + "formatQuote": "Quote", + "formatDivider": "Divider" + }, + + "contacts": { + "title": "Contacts", + "newContact": "New Contact", + "editContact": "Edit Contact", + "addButton": "New", + "newContactLabel": "New Contact", + "searchPlaceholder": "Search by name, phone or email…", + "importButton": "Import", + "importLabel": "Import contact from vCard", + "importTooltip": "Import vCard", + "emptyTitle": "No contacts yet", + "emptyDescription": "Add new contacts with the + button.", + "filterAll": "All", + "nameLabel": "Name *", + "namePlaceholder": "Full name", + "categoryLabel": "Category", + "phoneLabel": "Phone", + "phonePlaceholder": "+1 …", + "emailLabel": "Email", + "emailPlaceholder": "name@example.com", + "addressLabel": "Address", + "addressPlaceholder": "Street, ZIP City", + "notesLabel": "Notes", + "notesPlaceholder": "Optional…", + "callLabel": "Call", + "emailActionLabel": "Email", + "mapsLabel": "Open in Maps", + "exportLabel": "Export as vCard", + "exportTooltip": "Export vCard", + "deleteLabel": "Delete contact", + "deleteConfirm": "Really delete this contact?", + "deletePersonConfirm": "Really delete \"{{name}}\"?", + "savedToast": "Contact saved", + "updatedToast": "Contact updated", + "deletedToast": "Contact deleted", + "importedToast": "{{name}} imported.", + "importError": "Import failed: {{error}}", + "vcardNoName": "vCard does not contain a name.", + "catDoctor": "Doctor", + "catSchool": "School/Childcare", + "catAuthority": "Authority", + "catInsurance": "Insurance", + "catCraftsman": "Tradesperson", + "catEmergency": "Emergency", + "catMisc": "Miscellaneous" + }, + + "budget": { + "title": "Budget", + "newEntry": "New Entry", + "editEntry": "Edit Entry", + "addEntryLabel": "Add entry", + "newEntryFabLabel": "New Entry", + "currentMonth": "Current", + "prevMonth": "Previous month", + "nextMonth": "Next month", + "income": "Income", + "expenses": "Expenses", + "balance": "Balance", + "byCategory": "By category", + "transactions": "Transactions", + "emptyTitle": "No entries this month", + "emptyDescription": "Add budget entries with the + button.", + "csvExport": "CSV", + "typeExpense": "Expense", + "typeIncome": "Income", + "titleLabel": "Title *", + "titlePlaceholder": "e.g. Supermarket", + "amountLabel": "Amount (€) *", + "amountPlaceholder": "0.00", + "categoryLabel": "Category", + "dateLabel": "Date *", + "recurringLabel": "Recurring", + "deleteLabel": "Delete entry", + "deleteConfirm": "Really delete this entry?", + "deletePersonConfirm": "Really delete \"{{title}}\"?", + "addedToast": "Entry added", + "savedToast": "Entry saved", + "deletedToast": "Entry deleted", + "loadError": "Budget could not be loaded.", + "trendNeutral": "— same as {{month}}", + "validAmountRequired": "Please enter a valid amount", + "dateRequired": "Date is required", + "catFood": "Groceries", + "catRent": "Rent", + "catInsurance": "Insurance", + "catMobility": "Transport", + "catLeisure": "Leisure", + "catClothing": "Clothing", + "catHealth": "Health", + "catEducation": "Education", + "catMisc": "Miscellaneous", + "loadingIndicator": "Loading…" + }, + + "settings": { + "title": "Settings", + "sectionDesign": "Appearance", + "sectionAccount": "My Account", + "sectionCalendarSync": "Calendar Sync", + "sectionFamily": "Family Members", + "cardAppearance": "Display", + "themeSystem": "System", + "themeSysLabel": "Use system setting", + "themeLight": "Light", + "themeLightLabel": "Light mode", + "themeDark": "Dark", + "themeDarkLabel": "Dark mode", + "changePassword": "Change password", + "currentPasswordLabel": "Current password", + "newPasswordLabel": "New password", + "confirmPasswordLabel": "Confirm new password", + "savePassword": "Save password", + "passwordMismatch": "Passwords do not match.", + "passwordSavedToast": "Password changed successfully.", + "googleCalendar": "Google Calendar", + "appleCalendar": "Apple Calendar (iCloud)", + "syncNow": "Sync now", + "disconnect": "Disconnect", + "connectGoogle": "Connect with Google", + "connected": "Connected", + "connectedLastSync": "Connected · Last: {{date}}", + "notConnected": "Not connected", + "notConfigured": "Not configured (missing .env variables)", + "configured": "Configured (via .env)", + "configuredLastSync": "Configured (via .env) · Last: {{date}}", + "syncSuccess": "{{provider}} synced.", + "disconnectedToast": "{{provider}} disconnected.", + "googleOnlyAdmin": "Only admin can connect Google Calendar.", + "appleOnlyAdmin": "Only admin can connect Apple Calendar.", + "caldavUrlLabel": "CalDAV Server URL", + "caldavUrlPlaceholder": "https://caldav.icloud.com", + "appleIdLabel": "Apple ID (email)", + "applePasswordLabel": "App-specific password", + "applePasswordHint": "Create password at appleid.apple.com → Security.", + "appleConnectBtn": "Connect & test", + "appleConnecting": "Connecting…", + "appleConnectedToast": "Apple Calendar connected.", + "syncSuccessGoogle": "Calendar sync with Google connected successfully.", + "syncSuccessApple": "Calendar sync with Apple connected successfully.", + "syncErrorGoogle": "Connection to Google failed. Please try again.", + "syncErrorApple": "Connection to Apple failed. Please try again.", + "addMember": "+ Add member", + "newMemberTitle": "New Family Member", + "usernameLabel": "Username", + "displayNameLabel": "Display name", + "memberPasswordLabel": "Password", + "colorLabel": "Color", + "roleLabel": "Role", + "roleMember": "Member", + "roleAdmin": "Admin", + "createMember": "Create", + "cancelAddMember": "Cancel", + "memberAddedToast": "{{name}} added.", + "deleteMemberConfirm": "Really delete {{name}}?", + "memberDeletedToast": "{{name}} deleted.", + "deleteMemberLabel": "Delete", + "logout": "Log out", + "synchronizing": "Syncing…", + "googleDisconnectConfirm": "Disconnect Google Calendar?", + "appleDisconnectConfirm": "Disconnect Apple Calendar?" + }, + + "login": { + "tagline": "Family planning. Secure. Privacy-friendly. Open source.", + "usernameLabel": "Username", + "usernamePlaceholder": "username", + "passwordLabel": "Password", + "passwordPlaceholder": "••••••••", + "loginButton": "Log in", + "loggingIn": "Logging in…", + "tooManyAttempts": "Too many attempts. Please wait a moment.", + "invalidCredentials": "Invalid credentials." + }, + + "install": { + "title": "Install Oikos", + "subtitle": "Add to home screen", + "iosTip1": "Tap ", + "iosTip2": " → \"Add to Home Screen\"", + "installButton": "Install", + "dismissLabel": "Close" + }, + + "modal": { + "closeLabel": "Close" + } +} From af8f9ccb562804f0481f3353fe9a0c37d2eefc72 Mon Sep 17 00:00:00 2001 From: Ulas Date: Tue, 31 Mar 2026 21:53:14 +0200 Subject: [PATCH 04/17] feat: initialize i18n before first route render --- public/router.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/public/router.js b/public/router.js index e4e1745..e170fc6 100644 --- a/public/router.js +++ b/public/router.js @@ -5,6 +5,7 @@ */ import { auth } from '/api.js'; +import { initI18n, getLocale } from '/i18n.js'; // -------------------------------------------------------- // Routen-Definitionen @@ -430,7 +431,10 @@ if (window.visualViewport) { // -------------------------------------------------------- // Initialisierung // -------------------------------------------------------- -navigate(location.pathname, false); +(async () => { + await initI18n(); + navigate(location.pathname, false); +})(); // Globale Exporte window.oikos = { From f6a4879dd0983a457354ab045b66f651ccb1dd75 Mon Sep 17 00:00:00 2001 From: Ulas Date: Tue, 31 Mar 2026 22:31:57 +0200 Subject: [PATCH 05/17] feat: i18n login, dashboard, tasks pages --- public/pages/dashboard.js | 84 ++++++++--------- public/pages/login.js | 23 ++--- public/pages/tasks.js | 185 +++++++++++++++++++++----------------- 3 files changed, 160 insertions(+), 132 deletions(-) diff --git a/public/pages/dashboard.js b/public/pages/dashboard.js index ba0aaae..855a265 100644 --- a/public/pages/dashboard.js +++ b/public/pages/dashboard.js @@ -5,6 +5,7 @@ */ import { api } from '/api.js'; +import { t } from '/i18n.js'; // Hält den AbortController des aktuellen FAB-Listeners — wird bei jedem render() erneuert. let _fabController = null; @@ -15,8 +16,9 @@ let _fabController = null; function greeting(displayName) { const h = new Date().getHours(); - const tageszeit = h < 12 ? 'Morgen' : h < 18 ? 'Tag' : 'Abend'; - return `Guten ${tageszeit}, ${displayName}`; + if (h < 12) return t('dashboard.greetingMorning', { name: displayName }); + if (h < 18) return t('dashboard.greetingDay', { name: displayName }); + return t('dashboard.greetingEvening', { name: displayName }); } function formatDate(date = new Date()) { @@ -49,21 +51,21 @@ function formatDueDate(dateStr) { const diffMs = due - now; const diffH = diffMs / (1000 * 60 * 60); - if (diffMs < 0) return { text: 'Überfällig', overdue: true }; - if (diffH < 24) return { text: 'Heute fällig', overdue: false }; - if (diffH < 48) return { text: 'Morgen fällig', overdue: false }; + if (diffMs < 0) return { text: t('dashboard.overdue'), overdue: true }; + if (diffH < 24) return { text: t('dashboard.dueSoon'), overdue: false }; + if (diffH < 48) return { text: t('dashboard.dueTomorrow'), overdue: false }; return { text: due.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }), overdue: false, }; } -const MEAL_LABELS = { - breakfast: 'Frühstück', - lunch: 'Mittagessen', - dinner: 'Abendessen', - snack: 'Snack', -}; +const MEAL_LABELS = () => ({ + breakfast: t('meals.typeBreakfast'), + lunch: t('meals.typeLunch'), + dinner: t('meals.typeDinner'), + snack: t('meals.typeSnack'), +}); const MEAL_ICONS = { breakfast: 'sunrise', @@ -76,7 +78,8 @@ function initials(name = '') { return name.split(' ').map((w) => w[0]).join('').slice(0, 2).toUpperCase(); } -function widgetHeader(icon, title, count, linkHref, linkLabel = 'Alle') { +function widgetHeader(icon, title, count, linkHref, linkLabel) { + linkLabel = linkLabel ?? t('dashboard.allLink'); const badge = count != null ? `${count}` : ''; @@ -122,17 +125,17 @@ function renderGreeting(user, stats = {}) { if (urgentCount > 0) statChips.push(` - ${urgentCount} dring. Aufgabe${urgentCount > 1 ? 'n' : ''} + ${urgentCount > 1 ? t('dashboard.urgentTasksChipPlural', { count: urgentCount }) : t('dashboard.urgentTasksChip', { count: urgentCount })} `); if (todayEventCount > 0) statChips.push(` - ${todayEventCount} Termin${todayEventCount > 1 ? 'e' : ''} heute + ${todayEventCount > 1 ? t('dashboard.eventsChipPlural', { count: todayEventCount }) : t('dashboard.eventsChip', { count: todayEventCount })} `); if (todayMealTitle) statChips.push(` - Heute: ${todayMealTitle} + ${t('dashboard.todayMealChip', { title: todayMealTitle })} `); return ` @@ -149,10 +152,10 @@ function renderGreeting(user, stats = {}) { function renderUrgentTasks(tasks) { if (!tasks.length) { return `
- ${widgetHeader('check-square', 'Aufgaben', 0, '/tasks')} + ${widgetHeader('check-square', t('nav.tasks'), 0, '/tasks')}
-
Alles erledigt
+
${t('dashboard.allDone')}
`; } @@ -174,7 +177,7 @@ function renderUrgentTasks(tasks) { }).join(''); return `
- ${widgetHeader('check-square', 'Aufgaben', tasks.length, '/tasks')} + ${widgetHeader('check-square', t('nav.tasks'), tasks.length, '/tasks')}
${items}
`; } @@ -182,10 +185,10 @@ function renderUrgentTasks(tasks) { function renderUpcomingEvents(events) { if (!events.length) { return `
- ${widgetHeader('calendar', 'Termine', 0, '/calendar')} + ${widgetHeader('calendar', t('nav.calendar'), 0, '/calendar')}
-
Keine Termine
+
${t('dashboard.noEvents')}
`; } @@ -194,14 +197,14 @@ function renderUpcomingEvents(events) { const items = events.map((e) => { const d = new Date(e.start_datetime); const isToday = d.toDateString() === today; - const timeStr = e.all_day ? 'Ganztägig' : d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + ' Uhr'; + const timeStr = e.all_day ? t('dashboard.allDay') : d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + ' Uhr'; return `
${e.title}
- ${isToday ? 'Heute' : formatDateTime(e.start_datetime).split(',')[0]} + ${isToday ? t('common.today') : formatDateTime(e.start_datetime).split(',')[0]} ${timeStr} ${e.location ? ` · ${e.location}` : ''}
@@ -211,7 +214,7 @@ function renderUpcomingEvents(events) { }).join(''); return `
- ${widgetHeader('calendar', 'Termine', events.length, '/calendar')} + ${widgetHeader('calendar', t('nav.calendar'), events.length, '/calendar')}
${items}
`; } @@ -219,19 +222,20 @@ function renderUpcomingEvents(events) { function renderTodayMeals(meals) { const MEAL_ORDER = ['breakfast', 'lunch', 'dinner', 'snack']; + const mealLabels = MEAL_LABELS(); const slots = MEAL_ORDER.map((type) => { const meal = meals.find((m) => m.meal_type === type); return `
-
${MEAL_LABELS[type]}
+
${mealLabels[type]}
${meal ? meal.title : '—'}
`; }).join(''); return `
- ${widgetHeader('utensils', 'Heute essen', null, '/meals', 'Woche')} + ${widgetHeader('utensils', t('dashboard.todayMeals'), null, '/meals', t('dashboard.weekLink'))}
${slots}
`; } @@ -239,10 +243,10 @@ function renderTodayMeals(meals) { function renderPinnedNotes(notes) { if (!notes.length) { return `
- ${widgetHeader('pin', 'Pinnwand', 0, '/notes')} + ${widgetHeader('pin', t('nav.notes'), 0, '/notes')}
-
Keine angepinnten Notizen
+
${t('dashboard.noPinnedNotes')}
`; } @@ -256,7 +260,7 @@ function renderPinnedNotes(notes) { `).join(''); return `
- ${widgetHeader('pin', 'Pinnwand', notes.length, '/notes')} + ${widgetHeader('pin', t('nav.notes'), notes.length, '/notes')}
${items}
`; } @@ -290,7 +294,7 @@ function renderWeatherWidget(weather) { return `
-
@@ -300,7 +304,7 @@ function renderWeatherWidget(weather) {
${current.desc}
${city}
- Gefühlt ${current.feels_like}° · ${current.humidity}% · Wind ${current.wind_speed} km/h + ${t('dashboard.weatherFeelsLike', { temp: current.feels_like, humidity: current.humidity, wind: current.wind_speed })}
[ + { route: '/tasks', label: t('dashboard.fabTask'), icon: 'check-square' }, + { route: '/calendar', label: t('dashboard.fabCalendar'), icon: 'calendar-plus' }, + { route: '/shopping', label: t('dashboard.fabShopping'), icon: 'shopping-cart' }, + { route: '/notes', label: t('dashboard.fabNote'), icon: 'sticky-note' }, ]; function renderFab() { - const actionsHtml = FAB_ACTIONS.map((a) => ` + const actionsHtml = FAB_ACTIONS().map((a) => `
+ aria-label="${a.label}"> ${a.label} ${progress !== null ? `
+ aria-label="${t('tasks.subtaskToggle')}">
@@ -187,7 +206,7 @@ function renderTaskCard(task, opts = {}) { id="subtasks-${task.id}"> ${subtasksHtml}
` : ''}
`; @@ -200,8 +219,8 @@ function renderTaskGroups(tasks, groupMode) { -
Keine Aufgaben — alles erledigt?
-
Neue Aufgaben über den + Button erstellen.
+
${t('tasks.emptyTitle')}
+
${t('tasks.emptyDescription')}
`; } @@ -227,11 +246,12 @@ function renderModalContent({ task = null, users = [] } = {}) { `` ).join(''); + const catLabels = CATEGORY_LABELS(); const categoryOptions = CATEGORIES.map((c) => - `` + `` ).join(''); - const priorityOptions = PRIORITIES.map((p) => + const priorityOptions = PRIORITIES().map((p) => `` ).join(''); @@ -241,36 +261,36 @@ function renderModalContent({ task = null, users = [] } = {}) {
- +
- Dieses Feld ist erforderlich. + ${t('common.required')}
- +
- +
- + @@ -279,30 +299,30 @@ function renderModalContent({ task = null, users = [] } = {}) {
- +
- +
- +
${isEdit ? `
- + @@ -315,9 +335,9 @@ function renderModalContent({ task = null, users = [] } = {}) { `; @@ -375,7 +395,7 @@ async function loadTaskForEdit(id) { function openTaskModal({ task = null, users = [] } = {}, container) { const isEdit = !!task; openSharedModal({ - title: isEdit ? 'Aufgabe bearbeiten' : 'Neue Aufgabe', + title: isEdit ? t('tasks.editTask') : t('tasks.newTask'), content: renderModalContent({ task, users }), size: 'lg', onSave(panel) { @@ -408,9 +428,9 @@ async function handleFormSubmit(e, container) { errorEl.hidden = true; submitBtn.disabled = true; - submitBtn.textContent = 'Wird gespeichert…'; + submitBtn.textContent = t('common.saving'); - const originalLabel = taskId ? 'Speichern' : 'Erstellen'; + const originalLabel = taskId ? t('common.save') : t('common.create'); const rrule = getRRuleValues(document, 'task'); const body = { @@ -429,10 +449,10 @@ async function handleFormSubmit(e, container) { try { if (taskId) { await api.put(`/tasks/${taskId}`, body); - window.oikos.showToast('Aufgabe gespeichert.', 'success'); + window.oikos.showToast(t('tasks.savedToast'), 'success'); } else { await api.post('/tasks', body); - window.oikos.showToast('Aufgabe erstellt.', 'success'); + window.oikos.showToast(t('tasks.createdToast'), 'success'); } btnSuccess(submitBtn, originalLabel); setTimeout(() => closeModal(), 700); @@ -447,11 +467,11 @@ async function handleFormSubmit(e, container) { } async function handleDeleteTask(id, container) { - if (!confirm('Aufgabe und alle Teilaufgaben löschen?')) return; + if (!confirm(t('tasks.deleteConfirm'))) return; try { await api.delete(`/tasks/${id}`); closeModal(); - window.oikos.showToast('Aufgabe gelöscht.', 'default'); + window.oikos.showToast(t('tasks.deletedToast'), 'default'); await loadTasks(container); } catch (err) { window.oikos.showToast(err.message, 'danger'); @@ -459,7 +479,7 @@ async function handleDeleteTask(id, container) { } async function handleAddSubtask(parentId, container) { - const title = prompt('Teilaufgabe:'); + const title = prompt(t('tasks.subtaskPrompt')); if (!title?.trim()) return; try { await api.post('/tasks', { title: title.trim(), parent_task_id: parentId }); @@ -473,10 +493,10 @@ async function handleAddSubtask(parentId, container) { // Kanban-Ansicht // -------------------------------------------------------- -const KANBAN_COLS = [ - { status: 'open', label: 'Offen', colorVar: '--color-text-secondary' }, - { status: 'in_progress', label: 'In Bearbeitung', colorVar: '--color-warning' }, - { status: 'done', label: 'Erledigt', colorVar: '--color-success' }, +const KANBAN_COLS = () => [ + { status: 'open', label: t('tasks.kanbanOpen'), colorVar: '--color-text-secondary' }, + { status: 'in_progress', label: t('tasks.kanbanInProgress'), colorVar: '--color-warning' }, + { status: 'done', label: t('tasks.kanbanDone'), colorVar: '--color-success' }, ]; function renderKanbanCard(task) { @@ -503,8 +523,9 @@ function renderKanban(container) { const listEl = container.querySelector('#task-list'); if (!listEl) return; + const cols = KANBAN_COLS(); const grouped = {}; - for (const col of KANBAN_COLS) grouped[col.status] = []; + for (const col of cols) grouped[col.status] = []; for (const t of state.tasks) { if (grouped[t.status]) grouped[t.status].push(t); else grouped['open'].push(t); @@ -512,7 +533,7 @@ function renderKanban(container) { listEl.innerHTML = `
- ${KANBAN_COLS.map((col) => ` + ${cols.map((col) => `
@@ -606,7 +627,7 @@ function wireKanbanDrag(container) { const task = await loadTaskForEdit(card.dataset.taskId); openTaskModal({ task, users: state.users }, container); } catch (err) { - window.oikos.showToast('Aufgabe konnte nicht geladen werden.', 'danger'); + window.oikos.showToast(t('tasks.loadError'), 'danger'); } } }); @@ -635,15 +656,17 @@ function renderFilters(container) { if (!bar) return; const chips = []; + const statusLabels = STATUS_LABELS(); + const priorityLabels = PRIORITY_LABELS(); if (state.filters.status) { chips.push(` - ${STATUS_LABELS[state.filters.status]} + ${statusLabels[state.filters.status]} `); } if (state.filters.priority) { chips.push(` - ${PRIORITY_LABELS[state.filters.priority]} + ${priorityLabels[state.filters.priority]} `); } @@ -657,12 +680,12 @@ function renderFilters(container) { // Inaktive Filter-Chips (zum Aktivieren) if (!state.filters.status) { - STATUSES.forEach((s) => { + STATUSES().forEach((s) => { chips.push(`${s.label}`); }); } if (!state.filters.priority) { - PRIORITIES.forEach((p) => { + PRIORITIES().forEach((p) => { chips.push(`${p.label}`); }); } @@ -802,7 +825,7 @@ function wireSwipeGestures(container) { const task = await loadTaskForEdit(taskId); openTaskModal({ task, users: state.users }, container); } catch (err) { - window.oikos.showToast('Aufgabe konnte nicht geladen werden.', 'danger'); + window.oikos.showToast(t('tasks.loadError'), 'danger'); } } else { @@ -912,7 +935,7 @@ function wireTaskList(container) { const task = await loadTaskForEdit(id); openTaskModal({ task, users: state.users }, container); } catch (err) { - window.oikos.showToast('Aufgabe konnte nicht geladen werden.', 'danger'); + window.oikos.showToast(t('tasks.loadError'), 'danger'); } } @@ -931,7 +954,7 @@ export async function render(container, { user }) { container.innerHTML = `
-

Aufgaben

+

${t('tasks.title')}

- - + +
@@ -963,7 +986,7 @@ export async function render(container, { user }) {
`).join('')}
-
@@ -981,7 +1004,7 @@ export async function render(container, { user }) { state.users = metaData.users ?? []; } catch (err) { console.error('[Tasks] Ladefehler:', err.message); - window.oikos.showToast('Aufgaben konnten nicht geladen werden.', 'danger'); + window.oikos.showToast(t('tasks.loadError'), 'danger'); state.tasks = []; state.users = []; } From 752f6ee24dc30b8e7cc00c1e08f9270e617e2f8d Mon Sep 17 00:00:00 2001 From: Ulas Date: Tue, 31 Mar 2026 22:33:54 +0200 Subject: [PATCH 06/17] feat: add missing tasks keys to locales --- public/locales/de.json | 5 ++++- public/locales/en.json | 5 ++++- public/pages/tasks.js | 6 +++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/public/locales/de.json b/public/locales/de.json index e517219..e24ca70 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -135,7 +135,10 @@ "subtaskPrompt": "Teilaufgabe:", "kanbanOpen": "Offen", "kanbanInProgress": "In Bearbeitung", - "kanbanDone": "Erledigt" + "kanbanDone": "Erledigt", + "recurring": "Wiederkehrend", + "listView": "Listenansicht", + "kanbanView": "Kanban-Ansicht" }, "shopping": { diff --git a/public/locales/en.json b/public/locales/en.json index c6c2819..c728a43 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -135,7 +135,10 @@ "subtaskPrompt": "Subtask:", "kanbanOpen": "Open", "kanbanInProgress": "In Progress", - "kanbanDone": "Done" + "kanbanDone": "Done", + "recurring": "Recurring", + "listView": "List view", + "kanbanView": "Kanban view" }, "shopping": { diff --git a/public/pages/tasks.js b/public/pages/tasks.js index 955ae18..cee4b8a 100644 --- a/public/pages/tasks.js +++ b/public/pages/tasks.js @@ -175,7 +175,7 @@ function renderTaskCard(task, opts = {}) {
${renderPriorityBadge(task.priority)} ${renderDueDate(task.due_date)} - ${task.is_recurring ? '' : ''} + ${task.is_recurring ? `` : ''} ${task.category !== 'Sonstiges' ? `${CATEGORY_LABELS()[task.category] ?? task.category}` : ''}
@@ -958,11 +958,11 @@ export async function render(container, { user }) {
From 2f89e623b21e66c9b7a472915a7a04806950f721 Mon Sep 17 00:00:00 2001 From: Ulas Date: Tue, 31 Mar 2026 22:50:16 +0200 Subject: [PATCH 07/17] feat: i18n shopping, meals, calendar pages Co-Authored-By: Claude Sonnet 4.6 --- public/pages/calendar.js | 130 ++++++++++++++++++++++----------------- public/pages/meals.js | 107 +++++++++++++++++--------------- public/pages/shopping.js | 70 ++++++++++++--------- 3 files changed, 172 insertions(+), 135 deletions(-) diff --git a/public/pages/calendar.js b/public/pages/calendar.js index 4174ee4..4949046 100644 --- a/public/pages/calendar.js +++ b/public/pages/calendar.js @@ -8,17 +8,35 @@ import { api } from '/api.js'; import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js'; import { openModal as openSharedModal, closeModal } from '/components/modal.js'; import { stagger } from '/utils/ux.js'; +import { t } from '/i18n.js'; // -------------------------------------------------------- // Konstanten // -------------------------------------------------------- const VIEWS = ['month', 'week', 'day', 'agenda']; -const VIEW_LABELS = { month: 'Monat', week: 'Woche', day: 'Tag', agenda: 'Agenda' }; -const DAY_NAMES_SHORT = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']; -const DAY_NAMES_LONG = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']; -const MONTH_NAMES = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', - 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; +const VIEW_LABELS = () => ({ + month: t('calendar.viewMonth'), + week: t('calendar.viewWeek'), + day: t('calendar.viewDay'), + agenda: t('calendar.viewAgenda'), +}); +const DAY_NAMES_SHORT = () => [ + t('calendar.dayShortSunday'), t('calendar.dayShortMonday'), t('calendar.dayShortTuesday'), + t('calendar.dayShortWednesday'), t('calendar.dayShortThursday'), t('calendar.dayShortFriday'), + t('calendar.dayShortSaturday'), +]; +const DAY_NAMES_LONG = () => [ + t('calendar.dayLongSunday'), t('calendar.dayLongMonday'), t('calendar.dayLongTuesday'), + t('calendar.dayLongWednesday'), t('calendar.dayLongThursday'), t('calendar.dayLongFriday'), + t('calendar.dayLongSaturday'), +]; +const MONTH_NAMES = () => [ + t('calendar.monthJanuary'), t('calendar.monthFebruary'), t('calendar.monthMarch'), + t('calendar.monthApril'), t('calendar.monthMay'), t('calendar.monthJune'), + t('calendar.monthJuly'), t('calendar.monthAugust'), t('calendar.monthSeptember'), + t('calendar.monthOctober'), t('calendar.monthNovember'), t('calendar.monthDecember'), +]; const EVENT_COLORS = [ '#007AFF', '#34C759', '#FF9500', '#FF3B30', @@ -73,9 +91,9 @@ function getMondayOf(dateStr) { function formatDate(dateStr, { long = false, weekday = false } = {}) { const d = new Date(dateStr + 'T00:00:00'); const day = d.getDate(); - const mon = MONTH_NAMES[d.getMonth()]; + const mon = MONTH_NAMES()[d.getMonth()]; if (weekday) { - const wd = long ? DAY_NAMES_LONG[d.getDay()] : DAY_NAMES_SHORT[d.getDay()]; + const wd = long ? DAY_NAMES_LONG()[d.getDay()] : DAY_NAMES_SHORT()[d.getDay()]; return `${wd}, ${day}. ${mon}`; } return `${day}. ${mon} ${d.getFullYear()}`; @@ -132,7 +150,7 @@ async function loadRange(from, to) { } catch (err) { console.error('[Calendar] loadRange Fehler:', err); state.events = []; - window.oikos?.showToast('Termine konnten nicht geladen werden.', 'danger'); + window.oikos?.showToast(t('calendar.loadError'), 'danger'); } state.rangeFrom = from; state.rangeTo = to; @@ -161,7 +179,7 @@ export async function render(container, { user }) {
-
@@ -185,26 +203,26 @@ function renderToolbar() { if (!bar) return; bar.innerHTML = ` -

Kalender

+

${t('calendar.title')}

-
- +
${VIEWS.map((v) => ` + data-view="${v}">${VIEW_LABELS()[v]} `).join('')}
-
-
@@ -237,12 +255,12 @@ function updateLabel() { if (!lbl) return; const d = new Date(state.cursor + 'T00:00:00'); const year = d.getFullYear(); - const mon = MONTH_NAMES[d.getMonth()]; + const mon = MONTH_NAMES()[d.getMonth()]; if (state.view === 'month') lbl.textContent = `${mon} ${year}`; - if (state.view === 'week') lbl.textContent = `KW ${getWeekNumber(state.cursor)} · ${mon} ${year}`; + if (state.view === 'week') lbl.textContent = t('calendar.weekNumberLabel', { week: getWeekNumber(state.cursor), month: mon, year }); if (state.view === 'day') lbl.textContent = formatDate(state.cursor, { weekday: true, long: true }); - if (state.view === 'agenda') lbl.textContent = `Ab ${formatDate(state.cursor)}`; + if (state.view === 'agenda') lbl.textContent = t('calendar.agendaFrom', { date: formatDate(state.cursor) }); } function getWeekNumber(dateStr) { @@ -328,7 +346,7 @@ function renderMonthView(container) { container.innerHTML = `
- ${['Mo','Di','Mi','Do','Fr','Sa','So'].map((n) => `
${n}
`).join('')} + ${[t('calendar.dayShortMonday'),t('calendar.dayShortTuesday'),t('calendar.dayShortWednesday'),t('calendar.dayShortThursday'),t('calendar.dayShortFriday'),t('calendar.dayShortSaturday'),t('calendar.dayShortSunday')].map((n) => `
${n}
`).join('')}
${days.map(({ date, inMonth }) => renderMonthDay(date, inMonth)).join('')} @@ -376,7 +394,7 @@ function renderMonthDay(date, inMonth) {
${new Date(date + 'T00:00:00').getDate()}
${evHtml} - ${extra > 0 ? `
+${extra} weitere
` : ''} + ${extra > 0 ? `
${t('calendar.moreEvents', { count: extra })}
` : ''}
`; } @@ -404,14 +422,14 @@ function renderWeekView(container) { ${days.map((d) => { const dt = new Date(d + 'T00:00:00'); return `
-
${DAY_NAMES_SHORT[(dt.getDay())]}
+
${DAY_NAMES_SHORT()[dt.getDay()]}
${dt.getDate()}
`; }).join('')}
-
ganztg.
+
${t('calendar.allDayShort')}
${days.map((d, i) => `
${alldayEvs[i].map((ev) => ` @@ -523,7 +541,7 @@ function renderDayView(container) {
${allday.length ? `
-
ganztg.
+
${t('calendar.allDayShort')}
${allday.map((ev) => `
@@ -584,12 +602,12 @@ function renderAgendaView(container) { container.innerHTML = `
${groups.length === 0 - ? `
Keine Termine im gewählten Zeitraum.
` + ? `
${t('calendar.noEvents')}
` : groups.map(({ date, events }) => `
${formatDate(date)} - ${DAY_NAMES_LONG[new Date(date + 'T00:00:00').getDay()]} + ${DAY_NAMES_LONG()[new Date(date + 'T00:00:00').getDay()]}
${events.map((ev) => renderAgendaEvent(ev)).join('')}
@@ -611,7 +629,7 @@ function renderAgendaView(container) { function renderAgendaEvent(ev) { const timeStr = ev.all_day - ? 'Ganztägig' + ? t('calendar.allDay') : formatTime(ev.start_datetime) + (ev.end_datetime ? ` – ${formatTime(ev.end_datetime)} Uhr` : ' Uhr'); @@ -650,7 +668,7 @@ function showEventPopup(ev, anchor) { popup.className = 'event-popup'; const timeStr = ev.all_day - ? 'Ganztägig' + ? t('calendar.allDay') : formatDateTime(ev.start_datetime) + (ev.end_datetime ? ` – ${formatTime(ev.end_datetime)} Uhr` : ''); @@ -664,7 +682,7 @@ function showEventPopup(ev, anchor) { ${ev.assigned_name ? `
👤 ${escHtml(ev.assigned_name)}
` : ''}
- + @@ -687,7 +705,7 @@ function showEventPopup(ev, anchor) { }); popup.querySelector('#popup-delete').addEventListener('click', async () => { - if (!confirm(`"${ev.title}" wirklich löschen?`)) return; + if (!confirm(t('calendar.deleteConfirm', { title: ev.title }))) return; popup.remove(); await deleteEvent(ev.id); }); @@ -712,7 +730,7 @@ function openEventModal({ mode, event = null, date = null }) { const content = buildEventModalContent({ mode, event, date }); openSharedModal({ - title: isEdit ? 'Termin bearbeiten' : 'Neuer Termin', + title: isEdit ? t('calendar.editEvent') : t('calendar.newEvent'), content, size: 'md', onSave(panel) { @@ -745,7 +763,7 @@ function openEventModal({ mode, event = null, date = null }) { panel.querySelector('#modal-cancel').addEventListener('click', closeModal); panel.querySelector('#modal-delete')?.addEventListener('click', async () => { - if (!confirm(`"${event.title}" wirklich löschen?`)) return; + if (!confirm(t('calendar.deleteConfirm', { title: event.title }))) return; closeModal(); await deleteEvent(event.id); }); @@ -767,7 +785,7 @@ function buildEventModalContent({ mode, event, date }) { ? event.end_datetime.slice(11, 16) : '10:00'; const userOpts = [ - '', + ``, ...state.users.map((u) => `` ), @@ -775,36 +793,36 @@ function buildEventModalContent({ mode, event, date }) { return `
- + + placeholder="${t('calendar.titlePlaceholder')}" value="${escHtml(isEdit ? event.title : '')}">
- +
- +
- +
- +
@@ -813,29 +831,29 @@ function buildEventModalContent({ mode, event, date }) {
- + + placeholder="${t('calendar.locationPlaceholder')}" value="${escHtml(isEdit && event.location ? event.location : '')}">
- +
- +
${EVENT_COLORS.map((c) => `
- + + placeholder="${t('calendar.descriptionPlaceholder')}">${escHtml(isEdit && event.description ? event.description : '')}
${renderRRuleFields('event', isEdit ? event.recurrence_rule : null)} `; } @@ -868,7 +886,7 @@ async function saveEvent(overlay, mode, eventId) { const title = overlay.querySelector('#modal-title').value.trim(); if (!title) { - window.oikos?.showToast('Titel ist erforderlich', 'error'); + window.oikos?.showToast(t('calendar.titleRequired'), 'error'); return; } @@ -918,11 +936,11 @@ async function saveEvent(overlay, mode, eventId) { closeModal(); renderView(); - window.oikos?.showToast(mode === 'create' ? 'Termin erstellt' : 'Termin gespeichert', 'success'); + window.oikos?.showToast(mode === 'create' ? t('calendar.createdToast') : t('calendar.savedToast'), 'success'); } catch (err) { - window.oikos?.showToast(err.data?.error ?? 'Fehler beim Speichern', 'error'); + window.oikos?.showToast(err.data?.error ?? t('calendar.saveError'), 'error'); saveBtn.disabled = false; - saveBtn.textContent = mode === 'edit' ? 'Speichern' : 'Erstellen'; + saveBtn.textContent = mode === 'edit' ? t('common.save') : t('common.create'); } } @@ -931,9 +949,9 @@ async function deleteEvent(id) { await api.delete(`/calendar/${id}`); state.events = state.events.filter((e) => e.id !== id); renderView(); - window.oikos?.showToast('Termin gelöscht', 'success'); + window.oikos?.showToast(t('calendar.deletedToast'), 'success'); } catch (err) { - window.oikos?.showToast(err.data?.error ?? 'Fehler beim Löschen', 'error'); + window.oikos?.showToast(err.data?.error ?? t('calendar.deleteError'), 'error'); } } diff --git a/public/pages/meals.js b/public/pages/meals.js index d66cdf7..a7f9366 100644 --- a/public/pages/meals.js +++ b/public/pages/meals.js @@ -7,19 +7,23 @@ import { api } from '/api.js'; import { openModal as openSharedModal, closeModal as closeSharedModal } from '/components/modal.js'; import { stagger } from '/utils/ux.js'; +import { t } from '/i18n.js'; // -------------------------------------------------------- // Konstanten // -------------------------------------------------------- -const MEAL_TYPES = [ - { key: 'breakfast', label: 'Frühstück', icon: 'sunrise' }, - { key: 'lunch', label: 'Mittagessen', icon: 'sun' }, - { key: 'dinner', label: 'Abendessen', icon: 'moon' }, - { key: 'snack', label: 'Snack', icon: 'cookie' }, +const MEAL_TYPES = () => [ + { key: 'breakfast', label: t('meals.typeBreakfast'), icon: 'sunrise' }, + { key: 'lunch', label: t('meals.typeLunch'), icon: 'sun' }, + { key: 'dinner', label: t('meals.typeDinner'), icon: 'moon' }, + { key: 'snack', label: t('meals.typeSnack'), icon: 'cookie' }, ]; -const DAY_NAMES = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; +const DAY_NAMES = () => [ + t('meals.dayMo'), t('meals.dayDi'), t('meals.dayMi'), t('meals.dayDo'), + t('meals.dayFr'), t('meals.daySa'), t('meals.daySo'), +]; // -------------------------------------------------------- // State @@ -84,7 +88,7 @@ async function loadWeek(week) { console.error('[Meals] loadWeek Fehler:', err); state.meals = []; state.currentWeek = getMondayOf(week); - window.oikos?.showToast('Essensplan konnte nicht geladen werden.', 'danger'); + window.oikos?.showToast(t('meals.loadError'), 'danger'); } } @@ -105,19 +109,19 @@ export async function render(container, { user }) { _container = container; container.innerHTML = `
-

Essensplan

+

${t('meals.title')}

- - - +
-
Lade…
+
${t('meals.loadingIndicator')}
`; @@ -144,6 +148,7 @@ function renderWeekGrid() { formatWeekLabel(state.currentWeek); const days = Array.from({ length: 7 }, (_, i) => addDays(state.currentWeek, i)); + const dayNames = DAY_NAMES(); grid.innerHTML = days.map((date, idx) => { const mealsForDay = state.meals.filter((m) => m.date === date); @@ -152,11 +157,11 @@ function renderWeekGrid() { return `
- ${DAY_NAMES[idx]} + ${dayNames[idx]} ${formatDayDate(date)}
- ${MEAL_TYPES.map((type) => renderSlot(date, type, mealsForDay)).join('')} + ${MEAL_TYPES().map((type) => renderSlot(date, type, mealsForDay)).join('')}
`; @@ -175,14 +180,14 @@ function renderSlot(date, type, mealsForDay) {
${type.label}
-
Kein Essen geplant
+
${t('meals.noMealPlanned')}
@@ -192,7 +197,7 @@ function renderSlot(date, type, mealsForDay) { const ingCount = meal.ingredients?.length ?? 0; const ingDone = meal.ingredients?.filter((i) => i.on_shopping_list).length ?? 0; - const ingLabel = ingCount > 0 ? `${ingCount} Zutat${ingCount !== 1 ? 'en' : ''}` : ''; + const ingLabel = ingCount > 0 ? (ingCount !== 1 ? t('meals.ingredientCountPlural', { count: ingCount }) : t('meals.ingredientCount', { count: ingCount })) : ''; const ingDoneLabel = ingCount > 0 && ingDone === ingCount ? ' ✓' : ''; const canTransfer = ingCount > 0 && ingDone < ingCount; @@ -211,12 +216,12 @@ function renderSlot(date, type, mealsForDay) { ${canTransfer ? `` : ''}
@@ -429,7 +434,7 @@ function openMealModal(opts) { const content = buildModalContent(opts); openSharedModal({ - title: isEdit ? 'Mahlzeit bearbeiten' : 'Mahlzeit hinzufügen', + title: isEdit ? t('meals.editMeal') : t('meals.addMealTitle'), content, size: 'md', onSave(panel) { @@ -498,12 +503,12 @@ function openMealModal(opts) { try { const res = await api.post(`/meals/${state.modal.meal.id}/to-shopping-list`, { listId }); if (res.data.transferred > 0) { - window.oikos?.showToast(`${res.data.transferred} Zutat${res.data.transferred !== 1 ? 'en' : ''} übertragen`, 'success'); + window.oikos?.showToast(res.data.transferred !== 1 ? t('meals.transferSuccessPlural', { count: res.data.transferred }) : t('meals.transferSuccess', { count: res.data.transferred }), 'success'); await loadWeek(state.currentWeek); closeModal(); renderWeekGrid(); } else { - window.oikos?.showToast('Alle Zutaten bereits übertragen', 'info'); + window.oikos?.showToast(t('meals.transferAlreadyDone'), 'info'); btn.disabled = false; } } catch (err) { @@ -520,13 +525,13 @@ function openMealModal(opts) { function buildModalContent({ mode, date, mealType, meal }) { const isEdit = mode === 'edit'; - const typeOpts = MEAL_TYPES.map((t) => - `` + const typeOpts = MEAL_TYPES().map((mt) => + `` ).join(''); const listOpts = state.lists.length ? state.lists.map((l) => ``).join('') - : ''; + : ``; const ingRows = isEdit && meal.ingredients?.length ? meal.ingredients.map((ing) => ingredientRowHTML(ing.name, ing.quantity ?? '', ing.id)).join('') @@ -537,36 +542,36 @@ function buildModalContent({ mode, date, mealType, meal }) { return `
- +
- +
- +
- + + placeholder="${t('meals.notesPlaceholder')}">${escHtml(isEdit && meal.notes ? meal.notes : '')}
- +
${ingRows}
@@ -574,26 +579,26 @@ function buildModalContent({ mode, date, mealType, meal }) {
- Zutaten auf Einkaufsliste übertragen + ${t('meals.transferLabel')}
` : ''} `; } function ingredientRowHTML(name, qty, id) { return `
- - -
@@ -613,7 +618,7 @@ async function saveModal(overlay) { const notes = overlay.querySelector('#modal-notes').value.trim() || null; if (!title) { - window.oikos?.showToast('Titel ist erforderlich', 'error'); + window.oikos?.showToast(t('meals.titleRequired'), 'error'); return; } @@ -656,11 +661,11 @@ async function saveModal(overlay) { closeModal(); renderWeekGrid(); - window.oikos?.showToast(mode === 'create' ? 'Mahlzeit hinzugefügt' : 'Mahlzeit gespeichert', 'success'); + window.oikos?.showToast(mode === 'create' ? t('meals.addMealTitle') : t('meals.editMeal'), 'success'); } catch (err) { - window.oikos?.showToast(err.data?.error ?? 'Fehler beim Speichern', 'error'); + window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error'); saveBtn.disabled = false; - saveBtn.textContent = state.modal?.mode === 'edit' ? 'Speichern' : 'Hinzufügen'; + saveBtn.textContent = state.modal?.mode === 'edit' ? t('common.save') : t('common.add'); } } @@ -669,14 +674,14 @@ async function saveModal(overlay) { // -------------------------------------------------------- async function deleteMeal(mealId) { - if (!confirm('Mahlzeit wirklich löschen?')) return; + if (!confirm(t('meals.deleteMeal') + '?')) return; try { await api.delete(`/meals/${mealId}`); state.meals = state.meals.filter((m) => m.id !== mealId); renderWeekGrid(); - window.oikos?.showToast('Mahlzeit gelöscht', 'success'); + window.oikos?.showToast(t('meals.deleteMeal'), 'success'); } catch (err) { - window.oikos?.showToast(err.data?.error ?? 'Fehler beim Löschen', 'error'); + window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error'); } } @@ -686,7 +691,7 @@ async function deleteMeal(mealId) { async function transferMeal(mealId) { if (!state.lists.length) { - window.oikos?.showToast('Keine Einkaufslisten vorhanden', 'error'); + window.oikos?.showToast(t('meals.noShoppingLists'), 'error'); return; } @@ -703,14 +708,14 @@ async function transferMeal(mealId) { try { const res = await api.post(`/meals/${mealId}/to-shopping-list`, { listId }); if (res.data.transferred > 0) { - window.oikos?.showToast(`${res.data.transferred} Zutat${res.data.transferred !== 1 ? 'en' : ''} übertragen`, 'success'); + window.oikos?.showToast(res.data.transferred !== 1 ? t('meals.transferSuccessPlural', { count: res.data.transferred }) : t('meals.transferSuccess', { count: res.data.transferred }), 'success'); await loadWeek(state.currentWeek); renderWeekGrid(); } else { - window.oikos?.showToast('Alle Zutaten bereits übertragen', 'info'); + window.oikos?.showToast(t('meals.transferAlreadyDone'), 'info'); } } catch (err) { - window.oikos?.showToast(err.data?.error ?? 'Fehler beim Übertragen', 'error'); + window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error'); } } diff --git a/public/pages/shopping.js b/public/pages/shopping.js index cd15326..b6991dc 100644 --- a/public/pages/shopping.js +++ b/public/pages/shopping.js @@ -6,6 +6,7 @@ import { api } from '/api.js'; import { stagger, vibrate } from '/utils/ux.js'; +import { t } from '/i18n.js'; // -------------------------------------------------------- // Konstanten @@ -21,6 +22,18 @@ const ITEM_CATEGORIES = [ 'Tiefkühl', 'Getränke', 'Haushalt', 'Drogerie', 'Sonstiges', ]; +const CATEGORY_LABELS = () => ({ + 'Obst & Gemüse': t('shopping.catFruitVeg'), + 'Backwaren': t('shopping.catBakery'), + 'Milchprodukte': t('shopping.catDairy'), + 'Fleisch & Fisch': t('shopping.catMeatFish'), + 'Tiefkühl': t('shopping.catFrozen'), + 'Getränke': t('shopping.catDrinks'), + 'Haushalt': t('shopping.catHousehold'), + 'Drogerie': t('shopping.catDrugstore'), + 'Sonstiges': t('shopping.catMisc'), +}); + const CATEGORY_ICONS = { 'Obst & Gemüse': 'apple', 'Backwaren': 'wheat', @@ -95,9 +108,9 @@ function renderListContent(container) { content.innerHTML = `
-
Keine Listen
+
${t('shopping.noLists')}
- Erstelle eine Liste mit dem + Button. + ${t('shopping.noListsDescription')}
`; if (window.lucide) window.lucide.createIcons(); @@ -110,7 +123,7 @@ function renderListContent(container) {
+ role="button" tabindex="0" aria-label="${t('shopping.renameListLabel')}"> ${state.activeList.name} @@ -119,10 +132,10 @@ function renderListContent(container) { ` : ''} @@ -134,17 +147,17 @@ function renderListContent(container) {
+ placeholder="${t('shopping.itemNamePlaceholder')}" aria-label="${t('shopping.itemNameLabel')}" autocomplete="off"> + placeholder="${t('shopping.itemQtyPlaceholder')}" aria-label="${t('shopping.itemQtyLabel')}" autocomplete="off">
- ${ITEM_CATEGORIES.map((c) => `` ).join('')} -
@@ -170,17 +183,18 @@ function renderItems() { -
Die Liste ist leer
-
Artikel über das Eingabefeld oben hinzufügen.
+
${t('shopping.emptyList')}
+
${t('shopping.emptyListDescription')}
`; } + const catLabels = CATEGORY_LABELS(); const groups = groupItemsByCategory(state.items); return groups.map(([cat, items]) => `
- ${cat} + ${catLabels[cat] || cat}
${items.map(renderItem).join('')}
`).join(''); @@ -192,17 +206,17 @@ function renderItem(item) {
@@ -210,7 +224,7 @@ function renderItem(item) { ${item.quantity ? `
${escHtml(item.quantity)}
` : ''}
@@ -474,7 +488,7 @@ function updateItemsList(container) { `); if (window.lucide) window.lucide.createIcons(); } else if (clearBtn) { @@ -483,7 +497,7 @@ function updateItemsList(container) { } else { clearBtn.innerHTML = ` - Abgehakt löschen (${checkedCount})`; + ${t('shopping.clearChecked', { count: checkedCount })}`; if (window.lucide) window.lucide.createIcons(); } } @@ -509,7 +523,7 @@ async function loadLists() { } catch (err) { console.error('[Shopping] loadLists Fehler:', err); state.lists = []; - window.oikos?.showToast('Listen konnten nicht geladen werden.', 'danger'); + window.oikos?.showToast(t('shopping.listsLoadError'), 'danger'); } } @@ -528,7 +542,7 @@ async function switchList(listId, container) { console.error('[Shopping] loadItems Fehler:', err); state.items = []; state.activeList = state.lists.find((l) => l.id === listId) ?? null; - window.oikos?.showToast('Artikel konnten nicht geladen werden.', 'danger'); + window.oikos?.showToast(t('shopping.itemsLoadError'), 'danger'); } renderListContent(container); wireListContentEvents(container); @@ -548,7 +562,7 @@ function wireTabBar(container) { } if (target.dataset.action === 'new-list') { - const name = prompt('Name der neuen Liste:'); + const name = prompt(t('shopping.newListPrompt')); if (!name?.trim()) return; try { const data = await api.post('/shopping', { name: name.trim() }); @@ -621,7 +635,7 @@ function wireListContentEvents(container) { updateItemsList(container); updateListCounter(state.activeListId, -count, -count); renderTabs(container); - window.oikos.showToast(`${count} Artikel entfernt.`); + window.oikos.showToast(t('shopping.itemsRemovedToast', { count })); } catch (err) { window.oikos.showToast(err.message, 'danger'); } @@ -629,7 +643,7 @@ function wireListContentEvents(container) { // ---- Liste umbenennen ---- if (action === 'rename-list') { - const newName = prompt('Neuer Listen-Name:', state.activeList?.name); + const newName = prompt(t('shopping.renameListPrompt'), state.activeList?.name); if (!newName?.trim() || newName.trim() === state.activeList?.name) return; try { const data = await api.put(`/shopping/${state.activeListId}`, { name: newName.trim() }); @@ -646,7 +660,7 @@ function wireListContentEvents(container) { // ---- Liste löschen ---- if (action === 'delete-list') { - if (!confirm(`Liste "${state.activeList?.name}" und alle Artikel löschen?`)) return; + if (!confirm(t('shopping.deleteListConfirm', { name: state.activeList?.name }))) return; try { await api.delete(`/shopping/${state.activeListId}`); state.lists = state.lists.filter((l) => l.id !== state.activeListId); @@ -659,7 +673,7 @@ function wireListContentEvents(container) { renderTabs(container); renderListContent(container); } - window.oikos.showToast('Liste gelöscht.'); + window.oikos.showToast(t('shopping.deletedListToast')); } catch (err) { window.oikos.showToast(err.message, 'danger'); } @@ -701,15 +715,15 @@ export async function render(container, { user }) { } } catch (err) { console.error('[Shopping] Ladefehler:', err.message); - window.oikos.showToast('Einkaufslisten konnten nicht geladen werden.', 'danger'); + window.oikos.showToast(t('shopping.listsLoadError'), 'danger'); } container.innerHTML = `
-

Einkaufslisten

+

${t('shopping.title')}

-
From e6c6b0a4fc2a51ac14929eeba3c3ed0ee697e663 Mon Sep 17 00:00:00 2001 From: Ulas Date: Tue, 31 Mar 2026 22:52:51 +0200 Subject: [PATCH 08/17] feat: add missing calendar keys to locales Co-Authored-By: Claude Sonnet 4.6 --- public/locales/de.json | 4 +++- public/locales/en.json | 4 +++- public/pages/calendar.js | 6 +++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/public/locales/de.json b/public/locales/de.json index e24ca70..4283d52 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -296,7 +296,9 @@ "dayLongWednesday": "Mittwoch", "dayLongThursday": "Donnerstag", "dayLongFriday": "Freitag", - "dayLongSaturday": "Samstag" + "dayLongSaturday": "Samstag", + "timeSuffix": "Uhr", + "colorLabel": "Farbe {{color}}" }, "notes": { diff --git a/public/locales/en.json b/public/locales/en.json index c728a43..6179c9c 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -296,7 +296,9 @@ "dayLongWednesday": "Wednesday", "dayLongThursday": "Thursday", "dayLongFriday": "Friday", - "dayLongSaturday": "Saturday" + "dayLongSaturday": "Saturday", + "timeSuffix": "", + "colorLabel": "Color {{color}}" }, "notes": { diff --git a/public/pages/calendar.js b/public/pages/calendar.js index 4949046..7c6e91e 100644 --- a/public/pages/calendar.js +++ b/public/pages/calendar.js @@ -109,7 +109,7 @@ function formatDateTime(datetimeStr) { if (!datetimeStr) return ''; const date = datetimeStr.slice(0, 10); const time = datetimeStr.slice(11, 16); - return time ? `${formatDate(date)} ${time} Uhr` : formatDate(date); + return time ? `${formatDate(date)} ${time} ${t('calendar.timeSuffix')}`.trimEnd() : formatDate(date); } function getMonthRange(dateStr) { @@ -631,7 +631,7 @@ function renderAgendaEvent(ev) { const timeStr = ev.all_day ? t('calendar.allDay') : formatTime(ev.start_datetime) - + (ev.end_datetime ? ` – ${formatTime(ev.end_datetime)} Uhr` : ' Uhr'); + + (ev.end_datetime ? ` – ${formatTime(ev.end_datetime)} ${t('calendar.timeSuffix')}`.trimEnd() : ` ${t('calendar.timeSuffix')}`.trimEnd()); const initials = ev.assigned_name ? ev.assigned_name.split(' ').map((w) => w[0]).join('').toUpperCase().slice(0, 2) @@ -857,7 +857,7 @@ function buildEventModalContent({ mode, event, date }) {
${EVENT_COLORS.map((c) => ` + role="radio" tabindex="0" aria-label="${t('calendar.colorLabel', { color: c })}">
`).join('')}
From 26bbd61e1da2ddee17a21b3ce1cc0f58cd3c8864 Mon Sep 17 00:00:00 2001 From: Ulas Date: Tue, 31 Mar 2026 22:57:45 +0200 Subject: [PATCH 09/17] feat: i18n notes, contacts, budget, settings pages Co-Authored-By: Claude Sonnet 4.6 --- public/pages/budget.js | 92 ++++++++++++++---------- public/pages/contacts.js | 77 ++++++++++---------- public/pages/notes.js | 71 +++++++++--------- public/pages/settings.js | 151 ++++++++++++++++++++------------------- 4 files changed, 206 insertions(+), 185 deletions(-) diff --git a/public/pages/budget.js b/public/pages/budget.js index 301a209..bdfa7a2 100644 --- a/public/pages/budget.js +++ b/public/pages/budget.js @@ -8,6 +8,7 @@ import { api } from '/api.js'; import { openModal as openSharedModal, closeModal } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; +import { t } from '/i18n.js'; // -------------------------------------------------------- // Konstanten @@ -18,6 +19,18 @@ const CATEGORIES = [ 'Freizeit', 'Kleidung', 'Gesundheit', 'Bildung', 'Sonstiges', ]; +const CATEGORY_LABELS = () => ({ + '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'), +}); + const MONTH_NAMES = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; @@ -74,7 +87,7 @@ async function loadMonth(month) { state.entries = []; state.summary = { income: 0, expenses: 0, balance: 0, byCategory: [] }; state.prevSummary = null; - window.oikos?.showToast('Budget konnte nicht geladen werden.', 'danger'); + window.oikos?.showToast(t('budget.loadError'), 'danger'); } } @@ -89,24 +102,24 @@ export async function render(container, { user }) { container.innerHTML = `
-

Budget

+

${t('budget.title')}

- - + - -
-
Lade…
+
${t('budget.loadingIndicator')}
-
@@ -171,17 +184,17 @@ function renderBody() {
-
Einnahmen
+
${t('budget.income')}
${formatAmount(s.income)}
${p ? renderTrend(s.income, p.income, prevLabel) : ''}
-
Ausgaben
+
${t('budget.expenses')}
${formatAmount(Math.abs(s.expenses))}
${p ? renderTrend(s.expenses, p.expenses, prevLabel) : ''}
-
Saldo
+
${t('budget.balance')}
${formatAmount(s.balance)}
${p ? renderTrend(s.balance, p.balance, prevLabel) : ''}
@@ -190,7 +203,7 @@ function renderBody() { ${s.byCategory.length ? ` @@ -298,7 +311,7 @@ function renderEntries() { function renderTrend(current, prev, prevLabel) { const delta = current - prev; if (Math.abs(delta) < 0.005) { - return `
— wie ${prevLabel}
`; + return `
${t('budget.trendNeutral', { month: prevLabel })}
`; } const positive = delta > 0; const arrow = positive ? '▲' : '▼'; @@ -323,38 +336,39 @@ function openBudgetModal({ mode, entry = null }) { const isExpense = isEdit ? entry.amount < 0 : true; const absAmount = isEdit ? Math.abs(entry.amount).toFixed(2) : ''; + const catLabels = CATEGORY_LABELS(); const catOpts = CATEGORIES.map((c) => - `` + `` ).join(''); const content = `
+ id="type-expense" type="button">${t('budget.typeExpense')} + id="type-income" type="button">${t('budget.typeIncome')}
- + + placeholder="${t('budget.titlePlaceholder')}" value="${escHtml(isEdit ? entry.title : '')}">
- +
- +
- +
@@ -362,22 +376,22 @@ function openBudgetModal({ mode, entry = null }) {
`; openSharedModal({ - title: isEdit ? 'Eintrag bearbeiten' : 'Neuer Eintrag', + title: isEdit ? t('budget.editEntry') : t('budget.newEntry'), content, size: 'sm', onSave(panel) { @@ -397,7 +411,7 @@ function openBudgetModal({ mode, entry = null }) { panel.querySelector('#bm-cancel').addEventListener('click', closeModal); panel.querySelector('#bm-delete')?.addEventListener('click', async () => { - if (!confirm(`"${entry.title}" wirklich löschen?`)) return; + if (!confirm(t('budget.deletePersonConfirm', { title: entry.title }))) return; closeModal(); await deleteEntry(entry.id); }); @@ -410,9 +424,9 @@ function openBudgetModal({ mode, entry = null }) { const date = panel.querySelector('#bm-date').value; const recurring = panel.querySelector('#bm-recurring').checked ? 1 : 0; - if (!title) { window.oikos?.showToast('Titel ist erforderlich', 'error'); return; } - if (isNaN(absVal) || absVal <= 0) { window.oikos?.showToast('Gültigen Betrag eingeben', 'error'); return; } - if (!date) { window.oikos?.showToast('Datum ist erforderlich', 'error'); return; } + if (!title) { window.oikos?.showToast(t('common.titleRequired'), 'error'); return; } + if (isNaN(absVal) || absVal <= 0) { window.oikos?.showToast(t('budget.validAmountRequired'), 'error'); return; } + if (!date) { window.oikos?.showToast(t('budget.dateRequired'), 'error'); return; } const amount = currentType === 'expense' ? -absVal : absVal; @@ -434,11 +448,11 @@ function openBudgetModal({ mode, entry = null }) { closeModal(); renderBody(); - window.oikos?.showToast(mode === 'create' ? 'Eintrag hinzugefügt' : 'Eintrag gespeichert', 'success'); + window.oikos?.showToast(mode === 'create' ? t('budget.addedToast') : t('budget.savedToast'), 'success'); } catch (err) { window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error'); saveBtn.disabled = false; - saveBtn.textContent = isEdit ? 'Speichern' : 'Hinzufügen'; + saveBtn.textContent = isEdit ? t('common.save') : t('common.add'); } }); }, @@ -450,7 +464,7 @@ function openBudgetModal({ mode, entry = null }) { // -------------------------------------------------------- async function deleteEntry(id) { - if (!confirm('Eintrag wirklich löschen?')) return; + if (!confirm(t('budget.deleteConfirm'))) return; try { await api.delete(`/budget/${id}`); state.entries = state.entries.filter((e) => e.id !== id); @@ -458,7 +472,7 @@ async function deleteEntry(id) { state.summary = sumRes.data; renderBody(); vibrate([30, 50, 30]); - window.oikos?.showToast('Eintrag gelöscht', 'success'); + window.oikos?.showToast(t('budget.deletedToast'), 'success'); } catch (err) { window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error'); } diff --git a/public/pages/contacts.js b/public/pages/contacts.js index a19b225..57d2b3c 100644 --- a/public/pages/contacts.js +++ b/public/pages/contacts.js @@ -7,6 +7,7 @@ import { api } from '/api.js'; import { openModal as openSharedModal, closeModal } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; +import { t } from '/i18n.js'; // -------------------------------------------------------- // Konstanten @@ -44,32 +45,32 @@ export async function render(container, { user }) { _container = container; container.innerHTML = `
-

Kontakte

+

${t('contacts.title')}

-
- + ${CATEGORIES.map((c) => ` `).join('')}
-
@@ -115,13 +116,13 @@ export async function render(container, { user }) { try { const text = await file.text(); const contact = parseVCard(text); - if (!contact.name) { window.oikos?.showToast('vCard enthält keinen Namen.', 'warning'); return; } + if (!contact.name) { window.oikos?.showToast(t('contacts.vcardNoName'), 'warning'); return; } const res = await api.post('/contacts', contact); state.contacts.push(res.data); renderList(); - window.oikos?.showToast(`${res.data.name} importiert.`, 'success'); + window.oikos?.showToast(t('contacts.importedToast', { name: res.data.name }), 'success'); } catch (err) { - window.oikos?.showToast('Import fehlgeschlagen: ' + err.message, 'danger'); + window.oikos?.showToast(t('contacts.importError', { error: err.message }), 'danger'); } }); } @@ -164,8 +165,8 @@ function renderList() { -
Noch keine Kontakte
-
Neue Kontakte über den + Button hinzufügen.
+
${t('contacts.emptyTitle')}
+
${t('contacts.emptyDescription')}
`; if (window.lucide) lucide.createIcons(); @@ -207,9 +208,9 @@ function renderList() { } function renderContactItem(c) { - const phone = c.phone ? `` : ''; - const email = c.email ? `` : ''; - const maps = c.address ? `` : ''; + const phone = c.phone ? `` : ''; + const email = c.email ? `` : ''; + const maps = c.address ? `` : ''; const meta = [c.phone, c.email].filter(Boolean).join(' · '); return ` @@ -222,10 +223,10 @@ function renderContactItem(c) { @@ -247,49 +248,49 @@ function openContactModal({ mode, contact = null }) { const content = `
- - + +
- +
- - + +
- - + +
- - + +
- - + +
`; openSharedModal({ - title: isEdit ? 'Kontakt bearbeiten' : 'Neuer Kontakt', + title: isEdit ? t('contacts.editContact') : t('contacts.newContact'), content, size: 'md', onSave(panel) { panel.querySelector('#cm-cancel').addEventListener('click', closeModal); panel.querySelector('#cm-delete')?.addEventListener('click', async () => { - if (!confirm(`"${contact.name}" wirklich löschen?`)) return; + if (!confirm(t('contacts.deletePersonConfirm', { name: contact.name }))) return; closeModal(); await deleteContact(contact.id); }); @@ -303,7 +304,7 @@ function openContactModal({ mode, contact = null }) { const address = panel.querySelector('#cm-address').value.trim() || null; const notes = panel.querySelector('#cm-notes').value.trim() || null; - if (!name) { window.oikos?.showToast('Name ist erforderlich', 'error'); return; } + if (!name) { window.oikos?.showToast(t('common.nameRequired'), 'error'); return; } saveBtn.disabled = true; saveBtn.textContent = '…'; @@ -324,11 +325,11 @@ function openContactModal({ mode, contact = null }) { } closeModal(); renderList(); - window.oikos?.showToast(mode === 'create' ? 'Kontakt gespeichert' : 'Kontakt aktualisiert', 'success'); + window.oikos?.showToast(mode === 'create' ? t('contacts.savedToast') : t('contacts.updatedToast'), 'success'); } catch (err) { window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error'); saveBtn.disabled = false; - saveBtn.textContent = isEdit ? 'Speichern' : 'Erstellen'; + saveBtn.textContent = isEdit ? t('common.save') : t('common.create'); } }); }, @@ -336,13 +337,13 @@ function openContactModal({ mode, contact = null }) { } async function deleteContact(id) { - if (!confirm('Kontakt wirklich löschen?')) return; + if (!confirm(t('contacts.deleteConfirm'))) return; try { await api.delete(`/contacts/${id}`); state.contacts = state.contacts.filter((c) => c.id !== id); renderList(); vibrate([30, 50, 30]); - window.oikos?.showToast('Kontakt gelöscht', 'success'); + window.oikos?.showToast(t('contacts.deletedToast'), 'success'); } catch (err) { window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error'); } diff --git a/public/pages/notes.js b/public/pages/notes.js index 009c52b..bbbe076 100644 --- a/public/pages/notes.js +++ b/public/pages/notes.js @@ -7,6 +7,7 @@ import { api } from '/api.js'; import { openModal as openSharedModal, closeModal, btnError } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; +import { t } from '/i18n.js'; // -------------------------------------------------------- // Konstanten @@ -48,20 +49,20 @@ export async function render(container, { user }) { container.innerHTML = `
-

Pinnwand

+

${t('notes.title')}

-
@@ -75,7 +76,7 @@ export async function render(container, { user }) { } catch (err) { console.error('[Notes] Laden fehlgeschlagen:', err); state.notes = []; - window.oikos?.showToast('Notizen konnten nicht geladen werden.', 'danger'); + window.oikos?.showToast(t('notes.loadError'), 'danger'); } const grid = container.querySelector('#notes-grid'); grid.addEventListener('click', async (e) => { @@ -131,8 +132,8 @@ function renderGrid() { -
${isFiltered ? 'Keine Treffer' : 'Noch keine Notizen'}
-
${isFiltered ? `Keine Notiz enthält „${escHtml(state.filterQuery)}".` : 'Neue Notiz über den + Button erstellen.'}
+
${isFiltered ? t('notes.noResultsTitle') : t('notes.emptyTitle')}
+
${isFiltered ? t('notes.noResultsDescription', { query: state.filterQuery }) : t('notes.emptyDescription')}
`; if (window.lucide) lucide.createIcons(); @@ -156,7 +157,7 @@ function renderNoteCard(note) { data-id="${note.id}" style="background-color:${escHtml(note.color)};color:${textColor};"> ${note.title ? `
${escHtml(note.title)}
` : ''} @@ -167,7 +168,7 @@ function renderNoteCard(note) { style="background-color:${escHtml(note.creator_color || '#8E8E93')}">${initials} ${escHtml(note.creator_name || '')}
-
@@ -315,58 +316,58 @@ function openNoteModal({ mode, note = null }) { const content = `
- + + placeholder="${t('notes.titlePlaceholder')}" value="${escHtml(isEdit && note.title ? note.title : '')}">
- +
- - - - - - - - - - - -
- +
${NOTE_COLORS.map((c) => `
`; openSharedModal({ - title: isEdit ? 'Notiz bearbeiten' : 'Neue Notiz', + title: isEdit ? t('notes.editNote') : t('notes.newNote'), content, size: 'md', onSave(panel) { @@ -427,7 +428,7 @@ function openNoteModal({ mode, note = null }) { const color = panel.querySelector('.note-color-swatch--active')?.dataset.color || NOTE_COLORS[0]; const pinned = panel.querySelector('#note-pinned').checked ? 1 : 0; - if (!cnt) { window.oikos?.showToast('Inhalt ist erforderlich', 'error'); return; } + if (!cnt) { window.oikos?.showToast(t('common.contentRequired'), 'error'); return; } saveBtn.disabled = true; saveBtn.textContent = '…'; @@ -444,12 +445,12 @@ function openNoteModal({ mode, note = null }) { } closeModal(); renderGrid(); - window.oikos?.showToast(mode === 'create' ? 'Notiz erstellt' : 'Notiz gespeichert', 'success'); + window.oikos?.showToast(mode === 'create' ? t('notes.createdToast') : t('notes.savedToast'), 'success'); } catch (err) { window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error'); btnError(saveBtn); saveBtn.disabled = false; - saveBtn.textContent = isEdit ? 'Speichern' : 'Erstellen'; + saveBtn.textContent = isEdit ? t('common.save') : t('common.create'); } }); }, @@ -473,13 +474,13 @@ async function togglePin(id) { } async function deleteNote(id) { - if (!confirm('Notiz wirklich löschen?')) return; + if (!confirm(t('notes.deleteConfirm'))) return; try { await api.delete(`/notes/${id}`); state.notes = state.notes.filter((n) => n.id !== id); renderGrid(); vibrate([30, 50, 30]); - window.oikos?.showToast('Notiz gelöscht', 'success'); + window.oikos?.showToast(t('notes.deletedToast'), 'success'); } catch (err) { window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error'); } diff --git a/public/pages/settings.js b/public/pages/settings.js index 29c5f4d..66c2423 100644 --- a/public/pages/settings.js +++ b/public/pages/settings.js @@ -5,6 +5,7 @@ */ import { api, auth } from '/api.js'; +import { t } from '/i18n.js'; /** * @param {HTMLElement} container @@ -32,32 +33,42 @@ export async function render(container, { user }) { if (aStatus.status === 'fulfilled') appleStatus = aStatus.value; } catch (_) { /* non-critical */ } + const googleStatusText = googleStatus.connected + ? (googleStatus.lastSync ? t('settings.connectedLastSync', { date: formatDate(googleStatus.lastSync) }) : t('settings.connected')) + : googleStatus.configured ? t('settings.notConnected') : t('settings.notConfigured'); + + const appleStatusText = appleStatus.connected + ? (appleStatus.lastSync ? t('settings.connectedLastSync', { date: formatDate(appleStatus.lastSync) }) : t('settings.connected')) + : appleStatus.configured + ? (appleStatus.lastSync ? t('settings.configuredLastSync', { date: formatDate(appleStatus.lastSync) }) : t('settings.configured')) + : t('settings.notConnected'); + container.innerHTML = `
- ${syncOk ? `
Kalender-Sync mit ${syncOk === 'google' ? 'Google' : 'Apple'} erfolgreich verbunden.
` : ''} - ${syncErr ? `
Verbindung mit ${syncErr === 'google' ? 'Google' : 'Apple'} fehlgeschlagen. Bitte erneut versuchen.
` : ''} + ${syncOk ? `
${syncOk === 'google' ? t('settings.syncSuccessGoogle') : t('settings.syncSuccessApple')}
` : ''} + ${syncErr ? `
${syncErr === 'google' ? t('settings.syncErrorGoogle') : t('settings.syncErrorApple')}
` : ''}
-

Design

+

${t('settings.sectionDesign')}

-

Darstellung

+

${t('settings.cardAppearance')}

- - -
@@ -65,7 +76,7 @@ export async function render(container, { user }) {
-

Mein Konto

+

${t('settings.sectionAccount')}

-

Passwort ändern

+

${t('settings.changePassword')}

- +
- +
- +
- +
-

Kalender-Synchronisation

+

${t('settings.sectionCalendarSync')}

@@ -116,21 +127,19 @@ export async function render(container, { user }) {
-
Google Calendar
+
${t('settings.googleCalendar')}
- ${googleStatus.connected - ? `Verbunden${googleStatus.lastSync ? ` · Zuletzt: ${formatDate(googleStatus.lastSync)}` : ''}` - : googleStatus.configured ? 'Nicht verbunden' : 'Nicht konfiguriert (fehlende .env-Variablen)'} + ${googleStatusText}
${googleStatus.configured ? `
${googleStatus.connected ? ` - - ${user?.role === 'admin' ? `` : ''} + + ${user?.role === 'admin' ? `` : ''} ` : ` - ${user?.role === 'admin' ? `Mit Google verbinden` : 'Nur Admin kann Google Calendar verbinden.'} + ${user?.role === 'admin' ? `${t('settings.connectGoogle')}` : `${t('settings.googleOnlyAdmin')}`} `}
` : ''} @@ -145,84 +154,80 @@ export async function render(container, { user }) {
-
Apple Calendar (iCloud)
+
${t('settings.appleCalendar')}
- ${appleStatus.connected - ? `Verbunden${appleStatus.lastSync ? ` · Zuletzt: ${formatDate(appleStatus.lastSync)}` : ''}` - : appleStatus.configured - ? `Konfiguriert (via .env)${appleStatus.lastSync ? ` · Zuletzt: ${formatDate(appleStatus.lastSync)}` : ''}` - : 'Nicht verbunden'} + ${appleStatusText}
${appleStatus.configured ? `
- - ${appleStatus.connected && user?.role === 'admin' ? `` : ''} + + ${appleStatus.connected && user?.role === 'admin' ? `` : ''}
` : user?.role === 'admin' ? `
- - + +
- +
- + - Passwort unter appleid.apple.com → Sicherheit erstellen. + ${t('settings.applePasswordHint')}
- +
- ` : 'Nur Admin kann Apple Calendar verbinden.'} + ` : `${t('settings.appleOnlyAdmin')}`}
${user?.role === 'admin' ? `
-

Familienmitglieder

+

${t('settings.sectionFamily')}

    ${users.map(memberHtml).join('')}
- +
-

Neues Familienmitglied

+

${t('settings.newMemberTitle')}

- +
- +
- +
- +
- +
- - + +
@@ -231,7 +236,7 @@ export async function render(container, { user }) {
- +
`; @@ -270,7 +275,7 @@ function bindEvents(container, user) { errorEl.hidden = true; if (newPw !== confirmPw) { - showError(errorEl, 'Passwörter stimmen nicht überein.'); + showError(errorEl, t('settings.passwordMismatch')); return; } @@ -279,7 +284,7 @@ function bindEvents(container, user) { try { await api.patch('/auth/me/password', { current_password: currentPw, new_password: newPw }); passwordForm.reset(); - window.oikos?.showToast('Passwort erfolgreich geändert.', 'success'); + window.oikos?.showToast(t('settings.passwordSavedToast'), 'success'); } catch (err) { showError(errorEl, err.message); } finally { @@ -293,15 +298,15 @@ function bindEvents(container, user) { if (googleSyncBtn) { googleSyncBtn.addEventListener('click', async () => { googleSyncBtn.disabled = true; - googleSyncBtn.textContent = 'Synchronisiere…'; + googleSyncBtn.textContent = t('settings.synchronizing'); try { await api.post('/calendar/google/sync', {}); - window.oikos?.showToast('Google Calendar synchronisiert.', 'success'); + window.oikos?.showToast(t('settings.syncSuccess', { provider: 'Google Calendar' }), 'success'); } catch (err) { window.oikos?.showToast(err.message, 'danger'); } finally { googleSyncBtn.disabled = false; - googleSyncBtn.textContent = 'Jetzt synchronisieren'; + googleSyncBtn.textContent = t('settings.syncNow'); } }); } @@ -310,10 +315,10 @@ function bindEvents(container, user) { const googleDisconnectBtn = container.querySelector('#google-disconnect-btn'); if (googleDisconnectBtn) { googleDisconnectBtn.addEventListener('click', async () => { - if (!confirm('Google Calendar-Verbindung trennen?')) return; + if (!confirm(t('settings.googleDisconnectConfirm'))) return; try { await api.delete('/calendar/google/disconnect'); - window.oikos?.showToast('Google Calendar getrennt.', 'default'); + window.oikos?.showToast(t('settings.disconnectedToast', { provider: 'Google Calendar' }), 'default'); window.oikos?.navigate('/settings'); } catch (err) { window.oikos?.showToast(err.message, 'danger'); @@ -326,15 +331,15 @@ function bindEvents(container, user) { if (appleSyncBtn) { appleSyncBtn.addEventListener('click', async () => { appleSyncBtn.disabled = true; - appleSyncBtn.textContent = 'Synchronisiere…'; + appleSyncBtn.textContent = t('settings.synchronizing'); try { await api.post('/calendar/apple/sync', {}); - window.oikos?.showToast('Apple Calendar synchronisiert.', 'success'); + window.oikos?.showToast(t('settings.syncSuccess', { provider: 'Apple Calendar' }), 'success'); } catch (err) { window.oikos?.showToast(err.message, 'danger'); } finally { appleSyncBtn.disabled = false; - appleSyncBtn.textContent = 'Jetzt synchronisieren'; + appleSyncBtn.textContent = t('settings.syncNow'); } }); } @@ -343,10 +348,10 @@ function bindEvents(container, user) { const appleDisconnectBtn = container.querySelector('#apple-disconnect-btn'); if (appleDisconnectBtn) { appleDisconnectBtn.addEventListener('click', async () => { - if (!confirm('Apple Calendar-Verbindung trennen?')) return; + if (!confirm(t('settings.appleDisconnectConfirm'))) return; try { await api.delete('/calendar/apple/disconnect'); - window.oikos?.showToast('Apple Calendar getrennt.', 'default'); + window.oikos?.showToast(t('settings.disconnectedToast', { provider: 'Apple Calendar' }), 'default'); window.oikos?.navigate('/settings'); } catch (err) { window.oikos?.showToast(err.message, 'danger'); @@ -368,16 +373,16 @@ function bindEvents(container, user) { const btn = container.querySelector('#apple-connect-btn'); btn.disabled = true; - btn.textContent = 'Verbinde…'; + btn.textContent = t('settings.appleConnecting'); try { await api.post('/calendar/apple/connect', { url, username, password }); - window.oikos?.showToast('Apple Calendar verbunden.', 'success'); + window.oikos?.showToast(t('settings.appleConnectedToast'), 'success'); window.oikos?.navigate('/settings'); } catch (err) { showError(errorEl, err.message); } finally { btn.disabled = false; - btn.textContent = 'Verbinden & testen'; + btn.textContent = t('settings.appleConnectBtn'); } }); } @@ -425,7 +430,7 @@ function bindEvents(container, user) { addMemberForm.reset(); container.querySelector('#add-member-form-card').classList.add('settings-card--hidden'); container.querySelector('#add-member-btn').hidden = false; - window.oikos?.showToast(`${res.user.display_name} hinzugefügt.`, 'success'); + window.oikos?.showToast(t('settings.memberAddedToast', { name: res.user.display_name }), 'success'); bindDeleteButtons(container, user); } catch (err) { showError(errorEl, err.message); @@ -458,11 +463,11 @@ function bindDeleteButtons(container, user) { btn.addEventListener('click', async () => { const id = parseInt(btn.dataset.deleteUser, 10); const name = btn.dataset.name; - if (!confirm(`${name} wirklich löschen?`)) return; + if (!confirm(t('settings.deleteMemberConfirm', { name }))) return; try { await auth.deleteUser(id); btn.closest('.settings-member').remove(); - window.oikos?.showToast(`${name} gelöscht.`, 'default'); + window.oikos?.showToast(t('settings.memberDeletedToast', { name }), 'default'); } catch (err) { window.oikos?.showToast(err.message, 'danger'); } @@ -480,9 +485,9 @@ function memberHtml(u) {
${initials(u.display_name)}
${u.display_name} - @${u.username} · ${u.role === 'admin' ? 'Admin' : 'Mitglied'} + @${u.username} · ${u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleMember')}
- From 29e334c114c5e8949c4010805dfca98460b3ff91 Mon Sep 17 00:00:00 2001 From: Ulas Date: Tue, 31 Mar 2026 23:07:09 +0200 Subject: [PATCH 10/17] fix: i18n contact categories and budget month names --- public/locales/de.json | 9 ++++++++- public/locales/en.json | 9 ++++++++- public/pages/budget.js | 9 ++++++--- public/pages/contacts.js | 19 ++++++++++++++++--- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/public/locales/de.json b/public/locales/de.json index 4283d52..1002dc1 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -384,7 +384,14 @@ "catInsurance": "Versicherung", "catCraftsman": "Handwerker", "catEmergency": "Notfall", - "catMisc": "Sonstiges" + "catMisc": "Sonstiges", + "categoryDoctor": "Arzt", + "categorySchool": "Schule/Kita", + "categoryAuthority": "Behörde", + "categoryInsurance": "Versicherung", + "categoryCraftsman": "Handwerker", + "categoryEmergency": "Notfall", + "categoryOther": "Sonstiges" }, "budget": { diff --git a/public/locales/en.json b/public/locales/en.json index 6179c9c..e0b2754 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -384,7 +384,14 @@ "catInsurance": "Insurance", "catCraftsman": "Tradesperson", "catEmergency": "Emergency", - "catMisc": "Miscellaneous" + "catMisc": "Miscellaneous", + "categoryDoctor": "Doctor", + "categorySchool": "School/Daycare", + "categoryAuthority": "Authority", + "categoryInsurance": "Insurance", + "categoryCraftsman": "Tradesperson", + "categoryEmergency": "Emergency", + "categoryOther": "Other" }, "budget": { diff --git a/public/pages/budget.js b/public/pages/budget.js index bdfa7a2..4974b27 100644 --- a/public/pages/budget.js +++ b/public/pages/budget.js @@ -31,8 +31,11 @@ const CATEGORY_LABELS = () => ({ 'Sonstiges': t('budget.catMisc'), }); -const MONTH_NAMES = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', - 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; +function getMonthName(monthIndex) { + // monthIndex: 0-based (0=Januar, 11=Dezember) + const date = new Date(2000, monthIndex, 1); + return new Intl.DateTimeFormat(document.documentElement.lang || 'de', { month: 'long' }).format(date); +} // -------------------------------------------------------- // State @@ -56,7 +59,7 @@ function formatAmount(n) { function formatMonthLabel(ym) { const [y, m] = ym.split('-'); - return `${MONTH_NAMES[parseInt(m, 10) - 1]} ${y}`; + return `${getMonthName(parseInt(m, 10) - 1)} ${y}`; } function addMonths(ym, n) { diff --git a/public/pages/contacts.js b/public/pages/contacts.js index 57d2b3c..cf568e9 100644 --- a/public/pages/contacts.js +++ b/public/pages/contacts.js @@ -26,6 +26,18 @@ const CATEGORY_ICONS = { 'Sonstiges': '📋', }; +function CATEGORY_LABELS() { + return { + 'Arzt': t('contacts.categoryDoctor'), + 'Schule/Kita': t('contacts.categorySchool'), + 'Behörde': t('contacts.categoryAuthority'), + 'Versicherung': t('contacts.categoryInsurance'), + 'Handwerker': t('contacts.categoryCraftsman'), + 'Notfall': t('contacts.categoryEmergency'), + 'Sonstiges': t('contacts.categoryOther'), + }; +} + // -------------------------------------------------------- // State // -------------------------------------------------------- @@ -66,7 +78,7 @@ export async function render(container, { user }) {
${CATEGORIES.map((c) => ` - + `).join('')}
@@ -184,7 +196,7 @@ function renderList() { .sort(([a], [b]) => CATEGORIES.indexOf(a) - CATEGORIES.indexOf(b)) .map(([cat, items]) => `
-
${CATEGORY_ICONS[cat] || ''} ${escHtml(cat)}
+
${CATEGORY_ICONS[cat] || ''} ${CATEGORY_LABELS()[cat] || escHtml(cat)}
${items.map((c) => renderContactItem(c)).join('')}
`).join(''); @@ -242,8 +254,9 @@ function openContactModal({ mode, contact = null }) { const isEdit = mode === 'edit'; const v = (field) => escHtml(isEdit && contact[field] ? contact[field] : ''); + const catLabels = CATEGORY_LABELS(); const catOpts = CATEGORIES.map((c) => - `` + `` ).join(''); const content = ` From a21fe6afdd686e559d0a2c699bdeb3c8dc2e0a86 Mon Sep 17 00:00:00 2001 From: Ulas Date: Tue, 31 Mar 2026 23:13:41 +0200 Subject: [PATCH 11/17] fix: replace hardcoded Fehler fallbacks with t(common.unknownError) Co-Authored-By: Claude Sonnet 4.6 --- public/locales/de.json | 3 ++- public/locales/en.json | 3 ++- public/pages/budget.js | 4 ++-- public/pages/contacts.js | 4 ++-- public/pages/meals.js | 2 +- public/pages/notes.js | 6 +++--- 6 files changed, 12 insertions(+), 10 deletions(-) diff --git a/public/locales/de.json b/public/locales/de.json index 1002dc1..28ef092 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -25,7 +25,8 @@ "titleRequired": "Titel ist erforderlich", "nameRequired": "Name ist erforderlich", "contentRequired": "Inhalt ist erforderlich", - "all": "Alle" + "all": "Alle", + "unknownError": "Unbekannter Fehler" }, "nav": { diff --git a/public/locales/en.json b/public/locales/en.json index e0b2754..c33f4a4 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -25,7 +25,8 @@ "titleRequired": "Title is required", "nameRequired": "Name is required", "contentRequired": "Content is required", - "all": "All" + "all": "All", + "unknownError": "Unknown error" }, "nav": { diff --git a/public/pages/budget.js b/public/pages/budget.js index 4974b27..daa5db0 100644 --- a/public/pages/budget.js +++ b/public/pages/budget.js @@ -453,7 +453,7 @@ function openBudgetModal({ mode, entry = null }) { renderBody(); window.oikos?.showToast(mode === 'create' ? t('budget.addedToast') : t('budget.savedToast'), 'success'); } catch (err) { - window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error'); + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); saveBtn.disabled = false; saveBtn.textContent = isEdit ? t('common.save') : t('common.add'); } @@ -477,7 +477,7 @@ async function deleteEntry(id) { vibrate([30, 50, 30]); window.oikos?.showToast(t('budget.deletedToast'), 'success'); } catch (err) { - window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error'); + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); } } diff --git a/public/pages/contacts.js b/public/pages/contacts.js index cf568e9..f64a326 100644 --- a/public/pages/contacts.js +++ b/public/pages/contacts.js @@ -340,7 +340,7 @@ function openContactModal({ mode, contact = null }) { renderList(); window.oikos?.showToast(mode === 'create' ? t('contacts.savedToast') : t('contacts.updatedToast'), 'success'); } catch (err) { - window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error'); + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); saveBtn.disabled = false; saveBtn.textContent = isEdit ? t('common.save') : t('common.create'); } @@ -358,7 +358,7 @@ async function deleteContact(id) { vibrate([30, 50, 30]); window.oikos?.showToast(t('contacts.deletedToast'), 'success'); } catch (err) { - window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error'); + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); } } diff --git a/public/pages/meals.js b/public/pages/meals.js index a7f9366..7917379 100644 --- a/public/pages/meals.js +++ b/public/pages/meals.js @@ -512,7 +512,7 @@ function openMealModal(opts) { btn.disabled = false; } } catch (err) { - window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error'); + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); btn.disabled = false; } }); diff --git a/public/pages/notes.js b/public/pages/notes.js index bbbe076..ecaf171 100644 --- a/public/pages/notes.js +++ b/public/pages/notes.js @@ -447,7 +447,7 @@ function openNoteModal({ mode, note = null }) { renderGrid(); window.oikos?.showToast(mode === 'create' ? t('notes.createdToast') : t('notes.savedToast'), 'success'); } catch (err) { - window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error'); + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); btnError(saveBtn); saveBtn.disabled = false; saveBtn.textContent = isEdit ? t('common.save') : t('common.create'); @@ -469,7 +469,7 @@ async function togglePin(id) { state.notes.sort((a, b) => b.pinned - a.pinned); renderGrid(); } catch (err) { - window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error'); + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); } } @@ -482,7 +482,7 @@ async function deleteNote(id) { vibrate([30, 50, 30]); window.oikos?.showToast(t('notes.deletedToast'), 'success'); } catch (err) { - window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error'); + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); } } From fe70cbf979799e8af0a045f1017363ec2a4e4e2d Mon Sep 17 00:00:00 2001 From: Ulas Date: Tue, 31 Mar 2026 23:15:51 +0200 Subject: [PATCH 12/17] feat: i18n web components with locale-changed listener Replace hardcoded German strings in modal.js and oikos-install-prompt.js with t() calls; wire locale-changed event listener for live re-render on locale switch. Co-Authored-By: Claude Sonnet 4.6 --- public/components/modal.js | 5 +++- public/components/oikos-install-prompt.js | 28 +++++++++++++++++------ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/public/components/modal.js b/public/components/modal.js index 890d290..83e4ebd 100644 --- a/public/components/modal.js +++ b/public/components/modal.js @@ -4,12 +4,15 @@ * Focus-Restore, Scroll-Lock und aria-modal. * Auf Mobile: Bottom Sheet mit Swipe-to-Close und Slide-out-Animation. * Abhängigkeiten: CSS-Klassen aus layout.css (.modal-overlay, .modal-panel, etc.) + * i18n.js (t) * * API: * openModal({ title, content, onSave, onDelete, size }) → void * closeModal() → void */ +import { t } from '/i18n.js'; + let activeOverlay = null; let previouslyFocused = null; let focusTrapHandler = null; @@ -191,7 +194,7 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {} aria-labelledby="shared-modal-title"> diff --git a/public/components/oikos-install-prompt.js b/public/components/oikos-install-prompt.js index 2a0d407..5b5a3ff 100644 --- a/public/components/oikos-install-prompt.js +++ b/public/components/oikos-install-prompt.js @@ -1,7 +1,7 @@ /** * Modul: Install-Prompt Web Component * Zweck: Dezentes Banner für PWA-Installation (Chrome/Android) und iOS-Anleitung - * Abhängigkeiten: Design Tokens aus tokens.css (via CSS custom properties) + * Abhängigkeiten: Design Tokens aus tokens.css (via CSS custom properties), i18n.js (t) * * Verhalten: * - Chrome/Android: Fängt beforeinstallprompt ab, zeigt Install-Banner @@ -11,6 +11,8 @@ * - Timing: Banner erst nach 2 Nutzer-Interaktionen anzeigen */ +import { t } from '/i18n.js'; + const DISMISS_KEY = 'oikos-install-dismissed'; const DISMISS_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 Tage @@ -39,6 +41,14 @@ class OikosInstallPrompt extends HTMLElement { return; } + // locale-changed: Banner neu rendern wenn Sprache wechselt + this._onLocaleChanged = () => { + if (this._currentIsIOS !== undefined) { + this._showBanner(this._currentIsIOS); + } + }; + window.addEventListener('locale-changed', this._onLocaleChanged); + // Noch nicht genug Interaktionen const interactions = Number(localStorage.getItem(INTERACTION_KEY) || '0'); if (interactions < INTERACTION_THRESHOLD) { @@ -56,6 +66,9 @@ class OikosInstallPrompt extends HTMLElement { disconnectedCallback() { window.removeEventListener('beforeinstallprompt', this._onBeforeInstall); if (this._offInteraction) this._offInteraction(); + if (this._onLocaleChanged) { + window.removeEventListener('locale-changed', this._onLocaleChanged); + } } _waitForInteractions() { @@ -97,6 +110,7 @@ class OikosInstallPrompt extends HTMLElement { /** Banner rendern */ _showBanner(isIOS) { + this._currentIsIOS = isIOS; this._shadow.innerHTML = ''; const style = document.createElement('style'); @@ -242,7 +256,7 @@ class OikosInstallPrompt extends HTMLElement { const title = document.createElement('div'); title.className = 'title'; - title.textContent = 'Oikos installieren'; + title.textContent = t('install.title'); const subtitle = document.createElement('div'); subtitle.className = 'subtitle'; @@ -251,12 +265,12 @@ class OikosInstallPrompt extends HTMLElement { // iOS: Teilen-Icon als SVG inline subtitle.innerHTML = ''; subtitle.append( - document.createTextNode('Tippe auf '), + document.createTextNode(t('install.iosTip1')), this._createShareIcon(), - document.createTextNode(' → „Zum Home-Bildschirm"') + document.createTextNode(t('install.iosTip2')) ); } else { - subtitle.textContent = 'Zur App hinzufügen'; + subtitle.textContent = t('install.subtitle'); } text.appendChild(title); @@ -267,7 +281,7 @@ class OikosInstallPrompt extends HTMLElement { if (!isIOS) { const btn = document.createElement('button'); btn.className = 'btn-install'; - btn.textContent = 'Installieren'; + btn.textContent = t('install.installButton'); btn.addEventListener('click', () => this._onInstallClick()); banner.appendChild(btn); } @@ -275,7 +289,7 @@ class OikosInstallPrompt extends HTMLElement { // Dismiss-Button const dismiss = document.createElement('button'); dismiss.className = 'btn-dismiss'; - dismiss.setAttribute('aria-label', 'Schließen'); + dismiss.setAttribute('aria-label', t('install.dismissLabel')); dismiss.innerHTML = ``; dismiss.addEventListener('click', () => this._dismiss()); banner.appendChild(dismiss); From 3aefca0a4480aad60fb26ac64204ac340dc7026f Mon Sep 17 00:00:00 2001 From: Ulas Date: Tue, 31 Mar 2026 23:19:05 +0200 Subject: [PATCH 13/17] feat: i18n navigation labels Replace all hardcoded German strings in router.js (navItems labels, aria-labels, skip-link, error/toast messages) with t() calls. Add a locale-changed event listener that re-renders sidebar and bottom-nav items on language switch. --- public/router.js | 72 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 50 insertions(+), 22 deletions(-) diff --git a/public/router.js b/public/router.js index e170fc6..5f21124 100644 --- a/public/router.js +++ b/public/router.js @@ -5,7 +5,7 @@ */ import { auth } from '/api.js'; -import { initI18n, getLocale } from '/i18n.js'; +import { initI18n, getLocale, t } from '/i18n.js'; // -------------------------------------------------------- // Routen-Definitionen @@ -216,8 +216,8 @@ async function renderPage(route, previousPath = null) { */ function renderAppShell(container) { container.innerHTML = ` - Zum Inhalt springen -