diff --git a/public/i18n.js b/public/i18n.js index 64b88d9..9228070 100644 --- a/public/i18n.js +++ b/public/i18n.js @@ -89,6 +89,10 @@ function getDateFormatPreference() { return ['mdy', 'dmy', 'ymd'].includes(stored) ? stored : DEFAULT_DATE_FORMAT; } +export function getDateFormat() { + return getDateFormatPreference(); +} + function formatDateParts(date, useUtc = false) { const d = date instanceof Date ? date : new Date(date); if (isNaN(d.getTime())) return ''; @@ -121,6 +125,52 @@ export function formatDate(date) { return formatDateParts(date); } +export function dateInputPlaceholder() { + switch (getDateFormatPreference()) { + case 'dmy': return 'DD/MM/YYYY'; + case 'ymd': return 'YYYY-MM-DD'; + default: return 'MM/DD/YYYY'; + } +} + +export function formatDateInput(date) { + if (!date) return ''; + return formatDate(date); +} + +export function parseDateInput(value) { + const raw = String(value || '').trim(); + if (!raw) return ''; + + const isoMatch = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (isoMatch) return isValidDateParts(isoMatch[1], isoMatch[2], isoMatch[3]) ? raw : ''; + + const slashMatch = raw.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); + if (!slashMatch) return ''; + + const [, first, second, year] = slashMatch; + const [month, day] = getDateFormatPreference() === 'dmy' + ? [second, first] + : [first, second]; + + if (!isValidDateParts(year, month, day)) return ''; + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; +} + +export function isDateInputValid(value) { + const raw = String(value || '').trim(); + return !raw || !!parseDateInput(raw); +} + +function isValidDateParts(year, month, day) { + const y = Number(year); + const m = Number(month); + const d = Number(day); + if (!Number.isInteger(y) || !Number.isInteger(m) || !Number.isInteger(d)) return false; + const date = new Date(Date.UTC(y, m - 1, d)); + return date.getUTCFullYear() === y && date.getUTCMonth() === m - 1 && date.getUTCDate() === d; +} + /** Uhrzeit locale-aware formatieren */ export function formatTime(date) { if (date == null) return ''; diff --git a/public/locales/ar.json b/public/locales/ar.json index 35e3ed1..208e6e2 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -349,7 +349,9 @@ "ics": { "reset": "إعادة التعيين للأصل", "resetToast": "تم إعادة تعيين التغييرات." - } + }, + "iconLabel": "Icon", + "invalidDate": "Use a valid date in the selected date format." }, "notes": { "title": "لوحة الملاحظات", @@ -856,7 +858,17 @@ "notificationEnable": "تفعيل الإشعارات", "notificationEnabled": "الإشعارات نشطة", "notificationDenied": "الإشعارات محظورة", - "notificationHint": "احصل على إشعارات حتى عندما يكون التطبيق مفتوحًا." + "notificationHint": "احصل على إشعارات حتى عندما يكون التطبيق مفتوحًا.", + "offset2days": "2 days before", + "offset1week": "1 week before", + "offset2weeks": "2 weeks before", + "offsetCustom": "Custom...", + "customAmountLabel": "Number", + "customUnitLabel": "Unit", + "customMinutes": "Minutes", + "customHours": "Hours", + "customDays": "Days", + "customWeeks": "Weeks" }, "onboarding": { "step1Title": "Welcome to Oikos", @@ -872,4 +884,4 @@ "offline": { "banner": "Offline – reconnecting…" } -} \ No newline at end of file +} diff --git a/public/locales/de.json b/public/locales/de.json index 66c73cc..798a228 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -366,7 +366,9 @@ "ics": { "reset": "Auf Original zurücksetzen", "resetToast": "Änderungen zurückgesetzt." - } + }, + "iconLabel": "Icon", + "invalidDate": "Bitte ein gültiges Datum im ausgewählten Format verwenden." }, "notes": { "title": "Notizen", @@ -823,7 +825,17 @@ "notificationDenied": "Benachrichtigungen blockiert", "notificationHint": "Erhalte Benachrichtigungen auch wenn die App geöffnet ist.", "pendingBadgeTitle": "{{count}} fällige Erinnerung", - "pendingBadgeTitlePlural": "{{count}} fällige Erinnerungen" + "pendingBadgeTitlePlural": "{{count}} fällige Erinnerungen", + "offset2days": "2 Tage vorher", + "offset1week": "1 Woche vorher", + "offset2weeks": "2 Wochen vorher", + "offsetCustom": "Benutzerdefiniert...", + "customAmountLabel": "Anzahl", + "customUnitLabel": "Einheit", + "customMinutes": "Minuten", + "customHours": "Stunden", + "customDays": "Tage", + "customWeeks": "Wochen" }, "birthdays": { "title": "Geburtstage", @@ -893,24 +905,24 @@ "banner": "Offline – Verbindung wird wiederhergestellt…" }, "emptyHint": { - "tasks": "Tippe auf + um deine erste Aufgabe zu erstellen. Wische eine Karte nach links zum Löschen.", + "tasks": "Tippe auf + um deine erste Aufgabe zu erstellen. Wische eine Karte nach links zum Löschen.", "calendar": "Verbinde Google Kalender unter Einstellungen → Integrationen für automatische Synchronisation.", "shopping": "Füge Artikel hinzu und wische zum Abhaken oder Löschen.", - "notes": "Tippe auf + für eine neue Notiz. Notizen werden im Volltext durchsucht.", + "notes": "Tippe auf + für eine neue Notiz. Notizen werden im Volltext durchsucht.", "contacts": "Lege wichtige Kontakte an — Arzt, Schule, Notfall — für Schnellzugriff.", - "budget": "Erstelle Kategorien und trage Einnahmen und Ausgaben ein.", - "meals": "Plane Mahlzeiten für die Woche und verknüpfe Rezepte.", + "budget": "Erstelle Kategorien und trage Einnahmen und Ausgaben ein.", + "meals": "Plane Mahlzeiten für die Woche und verknüpfe Rezepte.", "birthdays": "Trage Geburtstage ein — du erhältst eine Erinnerung rechtzeitig.", - "recipes": "Lege Rezepte an und verknüpfe sie mit deiner Mahlzeitenplanung." + "recipes": "Lege Rezepte an und verknüpfe sie mit deiner Mahlzeitenplanung." }, "shortcuts": { - "search": "Suche öffnen", - "new": "Neuen Eintrag erstellen", - "help": "Tastenkombinationen", - "goDash": "Dashboard", + "search": "Suche öffnen", + "new": "Neuen Eintrag erstellen", + "help": "Tastenkombinationen", + "goDash": "Dashboard", "goTasks": "Aufgaben", - "goCal": "Kalender", - "goShop": "Einkaufsliste", + "goCal": "Kalender", + "goShop": "Einkaufsliste", "goNotes": "Notizen" } } diff --git a/public/locales/el.json b/public/locales/el.json index f61fb32..3766bb8 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -349,7 +349,9 @@ "ics": { "reset": "Επαναφορά στο αρχικό", "resetToast": "Οι αλλαγές επαναφέρθηκαν." - } + }, + "iconLabel": "Icon", + "invalidDate": "Use a valid date in the selected date format." }, "notes": { "title": "Σημειώσεις", @@ -856,7 +858,17 @@ "notificationEnable": "Ενεργοποίηση ειδοποιήσεων", "notificationEnabled": "Ειδοποιήσεις ενεργές", "notificationDenied": "Ειδοποιήσεις αποκλεισμένες", - "notificationHint": "Λάβετε ειδοποιήσεις ακόμα και όταν η εφαρμογή είναι ανοιχτή." + "notificationHint": "Λάβετε ειδοποιήσεις ακόμα και όταν η εφαρμογή είναι ανοιχτή.", + "offset2days": "2 days before", + "offset1week": "1 week before", + "offset2weeks": "2 weeks before", + "offsetCustom": "Custom...", + "customAmountLabel": "Number", + "customUnitLabel": "Unit", + "customMinutes": "Minutes", + "customHours": "Hours", + "customDays": "Days", + "customWeeks": "Weeks" }, "onboarding": { "step1Title": "Welcome to Oikos", @@ -872,4 +884,4 @@ "offline": { "banner": "Offline – reconnecting…" } -} \ No newline at end of file +} diff --git a/public/locales/en.json b/public/locales/en.json index 76c18b2..99dbc67 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -349,7 +349,9 @@ "ics": { "reset": "Reset to original", "resetToast": "Changes reset." - } + }, + "iconLabel": "Icon", + "invalidDate": "Use a valid date in the selected date format." }, "notes": { "title": "Board", @@ -798,7 +800,17 @@ "notificationDenied": "Notifications blocked", "notificationHint": "Receive notifications while the app is open.", "pendingBadgeTitle": "{{count}} reminder due", - "pendingBadgeTitlePlural": "{{count}} reminders due" + "pendingBadgeTitlePlural": "{{count}} reminders due", + "offset2days": "2 days before", + "offset1week": "1 week before", + "offset2weeks": "2 weeks before", + "offsetCustom": "Custom...", + "customAmountLabel": "Number", + "customUnitLabel": "Unit", + "customMinutes": "Minutes", + "customHours": "Hours", + "customDays": "Days", + "customWeeks": "Weeks" }, "birthdays": { "title": "Birthdays", @@ -894,4 +906,4 @@ "birthdays": "Add birthdays — you will receive a reminder in time.", "recipes": "Create recipes and link them to your meal planner." } -} \ No newline at end of file +} diff --git a/public/locales/es.json b/public/locales/es.json index c5adbf3..3b40e92 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -349,7 +349,9 @@ "ics": { "reset": "Restaurar original", "resetToast": "Cambios restablecidos." - } + }, + "iconLabel": "Icon", + "invalidDate": "Use a valid date in the selected date format." }, "notes": { "title": "Notas", @@ -856,7 +858,17 @@ "notificationEnable": "Activar notificaciones", "notificationEnabled": "Notificaciones activas", "notificationDenied": "Notificaciones bloqueadas", - "notificationHint": "Recibe notificaciones incluso cuando la aplicación está abierta." + "notificationHint": "Recibe notificaciones incluso cuando la aplicación está abierta.", + "offset2days": "2 days before", + "offset1week": "1 week before", + "offset2weeks": "2 weeks before", + "offsetCustom": "Custom...", + "customAmountLabel": "Number", + "customUnitLabel": "Unit", + "customMinutes": "Minutes", + "customHours": "Hours", + "customDays": "Days", + "customWeeks": "Weeks" }, "onboarding": { "step1Title": "Welcome to Oikos", @@ -872,4 +884,4 @@ "offline": { "banner": "Offline – reconnecting…" } -} \ No newline at end of file +} diff --git a/public/locales/fr.json b/public/locales/fr.json index 52f6f5d..70f6310 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -349,7 +349,9 @@ "ics": { "reset": "Réinitialiser", "resetToast": "Modifications annulées." - } + }, + "iconLabel": "Icon", + "invalidDate": "Use a valid date in the selected date format." }, "notes": { "title": "Notes", @@ -856,7 +858,17 @@ "notificationEnable": "Activer les notifications", "notificationEnabled": "Notifications actives", "notificationDenied": "Notifications bloquées", - "notificationHint": "Recevez des notifications même lorsque l'application est ouverte." + "notificationHint": "Recevez des notifications même lorsque l'application est ouverte.", + "offset2days": "2 days before", + "offset1week": "1 week before", + "offset2weeks": "2 weeks before", + "offsetCustom": "Custom...", + "customAmountLabel": "Number", + "customUnitLabel": "Unit", + "customMinutes": "Minutes", + "customHours": "Hours", + "customDays": "Days", + "customWeeks": "Weeks" }, "onboarding": { "step1Title": "Welcome to Oikos", @@ -872,4 +884,4 @@ "offline": { "banner": "Offline – reconnecting…" } -} \ No newline at end of file +} diff --git a/public/locales/hi.json b/public/locales/hi.json index 3652c2d..062c3dd 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -349,7 +349,9 @@ "ics": { "reset": "मूल पर वापस जाएं", "resetToast": "परिवर्तन रीसेट हो गए।" - } + }, + "iconLabel": "Icon", + "invalidDate": "Use a valid date in the selected date format." }, "notes": { "title": "नोट बोर्ड", @@ -856,7 +858,17 @@ "notificationEnable": "सूचनाएं सक्षम करें", "notificationEnabled": "सूचनाएं सक्रिय", "notificationDenied": "सूचनाएं अवरुद्ध", - "notificationHint": "ऐप खुली होने पर भी सूचनाएं प्राप्त करें।" + "notificationHint": "ऐप खुली होने पर भी सूचनाएं प्राप्त करें।", + "offset2days": "2 days before", + "offset1week": "1 week before", + "offset2weeks": "2 weeks before", + "offsetCustom": "Custom...", + "customAmountLabel": "Number", + "customUnitLabel": "Unit", + "customMinutes": "Minutes", + "customHours": "Hours", + "customDays": "Days", + "customWeeks": "Weeks" }, "onboarding": { "step1Title": "Welcome to Oikos", @@ -872,4 +884,4 @@ "offline": { "banner": "Offline – reconnecting…" } -} \ No newline at end of file +} diff --git a/public/locales/it.json b/public/locales/it.json index e823a10..6bd7786 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -349,7 +349,9 @@ "ics": { "reset": "Ripristina originale", "resetToast": "Modifiche ripristinate." - } + }, + "iconLabel": "Icon", + "invalidDate": "Use a valid date in the selected date format." }, "notes": { "title": "Bacheca", @@ -856,7 +858,17 @@ "notificationEnable": "Attiva notifiche", "notificationEnabled": "Notifiche attive", "notificationDenied": "Notifiche bloccate", - "notificationHint": "Ricevi notifiche anche quando l'app è aperta." + "notificationHint": "Ricevi notifiche anche quando l'app è aperta.", + "offset2days": "2 days before", + "offset1week": "1 week before", + "offset2weeks": "2 weeks before", + "offsetCustom": "Custom...", + "customAmountLabel": "Number", + "customUnitLabel": "Unit", + "customMinutes": "Minutes", + "customHours": "Hours", + "customDays": "Days", + "customWeeks": "Weeks" }, "onboarding": { "step1Title": "Welcome to Oikos", @@ -872,4 +884,4 @@ "offline": { "banner": "Offline – reconnecting…" } -} \ No newline at end of file +} diff --git a/public/locales/ja.json b/public/locales/ja.json index 99532e5..00e5874 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -349,7 +349,9 @@ "ics": { "reset": "元に戻す", "resetToast": "変更がリセットされました。" - } + }, + "iconLabel": "Icon", + "invalidDate": "Use a valid date in the selected date format." }, "notes": { "title": "メモボード", @@ -856,7 +858,17 @@ "notificationEnable": "通知を有効にする", "notificationEnabled": "通知が有効", "notificationDenied": "通知がブロックされています", - "notificationHint": "アプリが開いているときでも通知を受け取ります。" + "notificationHint": "アプリが開いているときでも通知を受け取ります。", + "offset2days": "2 days before", + "offset1week": "1 week before", + "offset2weeks": "2 weeks before", + "offsetCustom": "Custom...", + "customAmountLabel": "Number", + "customUnitLabel": "Unit", + "customMinutes": "Minutes", + "customHours": "Hours", + "customDays": "Days", + "customWeeks": "Weeks" }, "onboarding": { "step1Title": "Welcome to Oikos", @@ -872,4 +884,4 @@ "offline": { "banner": "Offline – reconnecting…" } -} \ No newline at end of file +} diff --git a/public/locales/pt.json b/public/locales/pt.json index 63f71a7..f0d49cc 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -349,7 +349,9 @@ "ics": { "reset": "Restaurar original", "resetToast": "Alterações restauradas." - } + }, + "iconLabel": "Ícone", + "invalidDate": "Use uma data válida no formato selecionado." }, "notes": { "title": "Quadro de notas", @@ -857,7 +859,17 @@ "notificationEnable": "Ativar notificações", "notificationEnabled": "Notificações ativas", "notificationDenied": "Notificações bloqueadas", - "notificationHint": "Receba notificações mesmo quando a aplicação está aberta." + "notificationHint": "Receba notificações mesmo quando a aplicação está aberta.", + "offset2days": "2 dias antes", + "offset1week": "1 semana antes", + "offset2weeks": "2 semanas antes", + "offsetCustom": "Personalizado...", + "customAmountLabel": "Número", + "customUnitLabel": "Unidade", + "customMinutes": "Minutos", + "customHours": "Horas", + "customDays": "Dias", + "customWeeks": "Semanas" }, "onboarding": { "step1Title": "Welcome to Oikos", @@ -873,4 +885,4 @@ "offline": { "banner": "Offline – reconnecting…" } -} \ No newline at end of file +} diff --git a/public/locales/ru.json b/public/locales/ru.json index 354d8d4..32fe2b2 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -349,7 +349,9 @@ "ics": { "reset": "Сбросить к исходному", "resetToast": "Изменения сброшены." - } + }, + "iconLabel": "Icon", + "invalidDate": "Use a valid date in the selected date format." }, "notes": { "title": "Заметки", @@ -856,7 +858,17 @@ "notificationEnable": "Включить уведомления", "notificationEnabled": "Уведомления активны", "notificationDenied": "Уведомления заблокированы", - "notificationHint": "Получайте уведомления, даже когда приложение открыто." + "notificationHint": "Получайте уведомления, даже когда приложение открыто.", + "offset2days": "2 days before", + "offset1week": "1 week before", + "offset2weeks": "2 weeks before", + "offsetCustom": "Custom...", + "customAmountLabel": "Number", + "customUnitLabel": "Unit", + "customMinutes": "Minutes", + "customHours": "Hours", + "customDays": "Days", + "customWeeks": "Weeks" }, "onboarding": { "step1Title": "Welcome to Oikos", @@ -872,4 +884,4 @@ "offline": { "banner": "Offline – reconnecting…" } -} \ No newline at end of file +} diff --git a/public/locales/sv.json b/public/locales/sv.json index b3871f1..0d8cd69 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -349,7 +349,9 @@ "ics": { "reset": "Återställ till original", "resetToast": "Ändringar återställda." - } + }, + "iconLabel": "Icon", + "invalidDate": "Use a valid date in the selected date format." }, "notes": { "title": "Anteckningar", @@ -856,7 +858,17 @@ "notificationEnable": "Aktivera notiser", "notificationEnabled": "Notiser aktiva", "notificationDenied": "Notiser blockerade", - "notificationHint": "Få notiser även när appen är öppen." + "notificationHint": "Få notiser även när appen är öppen.", + "offset2days": "2 days before", + "offset1week": "1 week before", + "offset2weeks": "2 weeks before", + "offsetCustom": "Custom...", + "customAmountLabel": "Number", + "customUnitLabel": "Unit", + "customMinutes": "Minutes", + "customHours": "Hours", + "customDays": "Days", + "customWeeks": "Weeks" }, "onboarding": { "step1Title": "Welcome to Oikos", @@ -872,4 +884,4 @@ "offline": { "banner": "Offline – reconnecting…" } -} \ No newline at end of file +} diff --git a/public/locales/tr.json b/public/locales/tr.json index b6d8720..0fcf71b 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -349,7 +349,9 @@ "ics": { "reset": "Orijinale sıfırla", "resetToast": "Değişiklikler sıfırlandı." - } + }, + "iconLabel": "Icon", + "invalidDate": "Use a valid date in the selected date format." }, "notes": { "title": "Notlar", @@ -856,7 +858,17 @@ "notificationEnable": "Bildirimleri etkinleştir", "notificationEnabled": "Bildirimler etkin", "notificationDenied": "Bildirimler engellendi", - "notificationHint": "Uygulama açıkken bile bildirim alın." + "notificationHint": "Uygulama açıkken bile bildirim alın.", + "offset2days": "2 days before", + "offset1week": "1 week before", + "offset2weeks": "2 weeks before", + "offsetCustom": "Custom...", + "customAmountLabel": "Number", + "customUnitLabel": "Unit", + "customMinutes": "Minutes", + "customHours": "Hours", + "customDays": "Days", + "customWeeks": "Weeks" }, "onboarding": { "step1Title": "Welcome to Oikos", @@ -872,4 +884,4 @@ "offline": { "banner": "Offline – reconnecting…" } -} \ No newline at end of file +} diff --git a/public/locales/uk.json b/public/locales/uk.json index b7caa69..7cb3a5f 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -349,7 +349,9 @@ "ics": { "reset": "Скинути до оригіналу", "resetToast": "Зміни скинуто." - } + }, + "iconLabel": "Icon", + "invalidDate": "Use a valid date in the selected date format." }, "notes": { "title": "Нотатки", @@ -798,7 +800,17 @@ "notificationDenied": "Сповіщення заблоковано", "notificationHint": "Отримуйте сповіщення, поки додаток відкрито.", "pendingBadgeTitle": "{{count}} нагадування", - "pendingBadgeTitlePlural": "{{count}} нагадувань" + "pendingBadgeTitlePlural": "{{count}} нагадувань", + "offset2days": "2 days before", + "offset1week": "1 week before", + "offset2weeks": "2 weeks before", + "offsetCustom": "Custom...", + "customAmountLabel": "Number", + "customUnitLabel": "Unit", + "customMinutes": "Minutes", + "customHours": "Hours", + "customDays": "Days", + "customWeeks": "Weeks" }, "recipes": { "title": "Recipes", @@ -872,4 +884,4 @@ "offline": { "banner": "Offline – reconnecting…" } -} \ No newline at end of file +} diff --git a/public/locales/zh.json b/public/locales/zh.json index abfb61a..31b8337 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -349,7 +349,9 @@ "ics": { "reset": "重置为原始", "resetToast": "更改已重置。" - } + }, + "iconLabel": "Icon", + "invalidDate": "Use a valid date in the selected date format." }, "notes": { "title": "便签板", @@ -856,7 +858,17 @@ "notificationEnable": "启用通知", "notificationEnabled": "通知已启用", "notificationDenied": "通知已被阻止", - "notificationHint": "即使应用程序打开时也能收到通知。" + "notificationHint": "即使应用程序打开时也能收到通知。", + "offset2days": "2 days before", + "offset1week": "1 week before", + "offset2weeks": "2 weeks before", + "offsetCustom": "Custom...", + "customAmountLabel": "Number", + "customUnitLabel": "Unit", + "customMinutes": "Minutes", + "customHours": "Hours", + "customDays": "Days", + "customWeeks": "Weeks" }, "onboarding": { "step1Title": "Welcome to Oikos", @@ -872,4 +884,4 @@ "offline": { "banner": "Offline – reconnecting…" } -} \ No newline at end of file +} diff --git a/public/pages/birthdays.js b/public/pages/birthdays.js index 47a92ba..0f12ac5 100644 --- a/public/pages/birthdays.js +++ b/public/pages/birthdays.js @@ -1,7 +1,7 @@ import { api } from '/api.js'; import { openModal as openSharedModal, closeModal, confirmModal } from '/components/modal.js'; import { stagger, deleteWithUndo } from '/utils/ux.js'; -import { t, formatDate } from '/i18n.js'; +import { t, formatDate, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid } from '/i18n.js'; import { esc } from '/utils/html.js'; let state = { @@ -282,7 +282,7 @@ function openBirthdayModal({ mode, birthday = null }) {
- +
@@ -315,6 +315,12 @@ function openBirthdayModal({ mode, birthday = null }) { preview.insertAdjacentHTML('beforeend', birthdayPreviewHtml(nameInput.value.trim(), photoData)); }; nameInput.addEventListener('input', renderPreview); + panel.querySelectorAll('.js-date-input').forEach((input) => { + input.addEventListener('blur', () => { + const parsed = parseDateInput(input.value); + if (parsed) input.value = formatDateInput(parsed); + }); + }); panel.querySelector('#bd-photo').addEventListener('change', async (e) => { const file = e.target.files?.[0]; if (!file) return; @@ -337,14 +343,16 @@ function openBirthdayModal({ mode, birthday = null }) { }); panel.querySelector('#bd-save').addEventListener('click', async () => { const saveBtn = panel.querySelector('#bd-save'); + const birthDateRaw = panel.querySelector('#bd-birth-date').value; + const birthDate = parseDateInput(birthDateRaw); const body = { name: panel.querySelector('#bd-name').value.trim(), - birth_date: panel.querySelector('#bd-birth-date').value, + birth_date: birthDate, notes: panel.querySelector('#bd-notes').value.trim(), photo_data: photoData, }; - if (!body.name || !body.birth_date) { + if (!body.name || !body.birth_date || !isDateInputValid(birthDateRaw)) { window.oikos?.showToast(t('birthdays.requiredFields'), 'warning'); return; } diff --git a/public/pages/budget.js b/public/pages/budget.js index db71031..00dfda6 100644 --- a/public/pages/budget.js +++ b/public/pages/budget.js @@ -8,7 +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, formatDate, getLocale } from '/i18n.js'; +import { t, formatDate, getLocale, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid } from '/i18n.js'; import { esc } from '/utils/html.js'; // -------------------------------------------------------- @@ -494,8 +494,8 @@ function openBudgetModal({ mode, entry = null }) {
- +
@@ -604,6 +604,12 @@ function openBudgetModal({ mode, entry = null }) { panel.querySelector('#bm-category').addEventListener('change', () => updateSubcategoryOptions()); panel.querySelector('#bm-add-category').addEventListener('click', addCategory); panel.querySelector('#bm-add-subcategory').addEventListener('click', addSubcategory); + panel.querySelectorAll('.js-date-input').forEach((input) => { + input.addEventListener('blur', () => { + const parsed = parseDateInput(input.value); + if (parsed) input.value = formatDateInput(parsed); + }); + }); panel.querySelector('#bm-cancel').addEventListener('click', closeModal); @@ -618,12 +624,13 @@ function openBudgetModal({ mode, entry = null }) { const absVal = parseFloat(panel.querySelector('#bm-amount').value); const category = panel.querySelector('#bm-category').value; const subcategory = currentType === 'expense' ? panel.querySelector('#bm-subcategory').value : ''; - const date = panel.querySelector('#bm-date').value; + const dateRaw = panel.querySelector('#bm-date').value; + const date = parseDateInput(dateRaw); const recurring = panel.querySelector('#bm-recurring').checked ? 1 : 0; 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; } + if (!date || !isDateInputValid(dateRaw)) { window.oikos?.showToast(t('calendar.invalidDate'), 'error'); return; } const amount = currentType === 'expense' ? -absVal : absVal; diff --git a/public/pages/calendar.js b/public/pages/calendar.js index 7e86cf4..12d1fb3 100644 --- a/public/pages/calendar.js +++ b/public/pages/calendar.js @@ -8,7 +8,7 @@ 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, formatTime } from '/i18n.js'; +import { t, formatDate as formatPreferredDate, formatTime, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid } from '/i18n.js'; import { esc, fmtLocation } from '/utils/html.js'; import { refresh as refreshReminders } from '/reminders.js'; @@ -59,6 +59,115 @@ const EVENT_COLOR_NAMES = () => ({ '#30B0C7': t('calendar.colorCyan'), }); +const EVENT_ICON_ALIASES = { + tooth: 'drill', +}; + +const EVENT_ICONS = [ + { value: 'calendar', label: 'Calendar' }, + { value: 'drill', label: 'Dentist' }, + { value: 'alarm-clock', label: 'Alarm' }, + { value: 'clock', label: 'Time' }, + { value: 'bell', label: 'Reminder' }, + { value: 'map-pin', label: 'Location' }, + { value: 'home', label: 'Home' }, + { value: 'house', label: 'House' }, + { value: 'building', label: 'Building' }, + { value: 'hospital', label: 'Hospital' }, + { value: 'stethoscope', label: 'Doctor' }, + { value: 'syringe', label: 'Vaccine' }, + { value: 'pill', label: 'Medicine' }, + { value: 'tablets', label: 'Tablets' }, + { value: 'bandage', label: 'Bandage' }, + { value: 'ambulance', label: 'Ambulance' }, + { value: 'heart-pulse', label: 'Health' }, + { value: 'activity', label: 'Activity' }, + { value: 'cross', label: 'Care' }, + { value: 'scissors', label: 'Haircut' }, + { value: 'shower-head', label: 'Personal care' }, + { value: 'dumbbell', label: 'Sports' }, + { value: 'trophy', label: 'Competition' }, + { value: 'car', label: 'Car' }, + { value: 'bus', label: 'Bus' }, + { value: 'train', label: 'Train' }, + { value: 'tram-front', label: 'Transit' }, + { value: 'fuel', label: 'Fuel' }, + { value: 'parking-meter', label: 'Parking' }, + { value: 'traffic-cone', label: 'Traffic' }, + { value: 'navigation', label: 'Navigation' }, + { value: 'route', label: 'Route' }, + { value: 'briefcase', label: 'Work' }, + { value: 'laptop', label: 'Laptop' }, + { value: 'monitor', label: 'Computer' }, + { value: 'presentation', label: 'Presentation' }, + { value: 'plane', label: 'Travel' }, + { value: 'plane-takeoff', label: 'Flight' }, + { value: 'school', label: 'School' }, + { value: 'graduation-cap', label: 'Education' }, + { value: 'book-open', label: 'Reading' }, + { value: 'library', label: 'Library' }, + { value: 'pencil', label: 'Study' }, + { value: 'notebook-pen', label: 'Notes' }, + { value: 'calculator', label: 'Calculator' }, + { value: 'utensils', label: 'Meal' }, + { value: 'cooking-pot', label: 'Cooking' }, + { value: 'coffee', label: 'Coffee' }, + { value: 'cake', label: 'Birthday' }, + { value: 'croissant', label: 'Bakery' }, + { value: 'pizza', label: 'Pizza' }, + { value: 'ice-cream', label: 'Dessert' }, + { value: 'beer', label: 'Bar' }, + { value: 'wine', label: 'Wine' }, + { value: 'popcorn', label: 'Cinema' }, + { value: 'sandwich', label: 'Snack' }, + { value: 'salad', label: 'Salad' }, + { value: 'shopping-bag', label: 'Shopping' }, + { value: 'shopping-cart', label: 'Groceries' }, + { value: 'gift', label: 'Gift' }, + { value: 'package', label: 'Package' }, + { value: 'shirt', label: 'Clothing' }, + { value: 'tag', label: 'Tag' }, + { value: 'credit-card', label: 'Card' }, + { value: 'wallet', label: 'Wallet' }, + { value: 'banknote', label: 'Cash' }, + { value: 'coins', label: 'Coins' }, + { value: 'piggy-bank', label: 'Savings' }, + { value: 'receipt', label: 'Receipt' }, + { value: 'landmark', label: 'Bank' }, + { value: 'music', label: 'Music' }, + { value: 'guitar', label: 'Guitar' }, + { value: 'film', label: 'Movie' }, + { value: 'theater', label: 'Theater' }, + { value: 'ticket', label: 'Ticket' }, + { value: 'gamepad-2', label: 'Game' }, + { value: 'camera', label: 'Photo' }, + { value: 'party-popper', label: 'Party' }, + { value: 'users', label: 'Family' }, + { value: 'baby', label: 'Baby' }, + { value: 'dog', label: 'Dog' }, + { value: 'cat', label: 'Cat' }, + { value: 'paw-print', label: 'Pet' }, + { value: 'wrench', label: 'Repair' }, + { value: 'hammer', label: 'Maintenance' }, + { value: 'paintbrush', label: 'Decoration' }, + { value: 'lightbulb', label: 'Idea' }, + { value: 'sofa', label: 'Furniture' }, + { value: 'bed', label: 'Bed' }, + { value: 'bath', label: 'Bath' }, + { value: 'washing-machine', label: 'Laundry' }, + { value: 'refrigerator', label: 'Fridge' }, + { value: 'star', label: 'Favorite' }, + { value: 'flag', label: 'Flag' }, + { value: 'target', label: 'Goal' }, + { value: 'flame', label: 'Important' }, + { value: 'leaf', label: 'Nature' }, + { value: 'tree-pine', label: 'Outdoors' }, + { value: 'flower', label: 'Flower' }, + { value: 'sun', label: 'Day' }, + { value: 'moon', label: 'Night' }, + { value: 'cloud-sun', label: 'Weather' }, +]; + const HOUR_HEIGHT = 56; // px pro Stunde in Wochen-/Tagesansicht /** @@ -136,14 +245,12 @@ 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()]; if (weekday) { + const d = new Date(dateStr + 'T00:00:00'); const wd = long ? DAY_NAMES_LONG()[d.getDay()] : DAY_NAMES_SHORT()[d.getDay()]; - return `${wd}, ${day}. ${mon}`; + return `${wd}, ${formatPreferredDate(dateStr)}`; } - return `${day}. ${mon} ${d.getFullYear()}`; + return formatPreferredDate(dateStr); } function formatDateTime(datetimeStr) { @@ -154,6 +261,28 @@ function formatDateTime(datetimeStr) { return time ? `${formatDate(date)} ${time} ${t('calendar.timeSuffix')}`.trimEnd() : formatDate(date); } +function eventIconName(icon) { + const normalized = EVENT_ICON_ALIASES[icon] || icon; + return EVENT_ICONS.some((item) => item.value === normalized) ? normalized : 'calendar'; +} + +function eventIconHtml(icon, className = 'event-icon') { + return ``; +} + +function bindDateInputs(root) { + root.querySelectorAll('.js-date-input').forEach((input) => { + input.addEventListener('blur', () => { + const parsed = parseDateInput(input.value); + if (parsed) input.value = formatDateInput(parsed); + }); + }); +} + +function readDateInput(root, selector) { + return parseDateInput(root.querySelector(selector)?.value || ''); +} + function getMonthRange(dateStr) { const d = new Date(dateStr + 'T00:00:00'); const year = d.getFullYear(); @@ -358,6 +487,7 @@ function renderView() { if (state.view === 'week') renderWeekView(body); if (state.view === 'day') renderDayView(body); if (state.view === 'agenda') renderAgendaView(body); + if (window.lucide) lucide.createIcons(); } // -------------------------------------------------------- @@ -432,7 +562,7 @@ function renderMonthDay(date, inMonth) { data-id="${ev.id}" style="background-color:${esc(bg)};${fg ? `color:${fg};` : ''}" title="${esc(ev.title)}${ev.cal_name ? ' · ' + ev.cal_name : ''}" - >${esc(ev.title)}
+ >${eventIconHtml(ev.icon, 'event-icon event-icon--compact')}${esc(ev.title)}
`; }).join(''); @@ -481,7 +611,7 @@ function renderWeekView(container) { ${alldayEvs[i].map((ev) => `
${esc(ev.title)}
+ title="${esc(ev.title)}${ev.cal_name ? ' · ' + ev.cal_name : ''}">${eventIconHtml(ev.icon, 'event-icon event-icon--compact')}${esc(ev.title)} `).join('')} `).join('')} @@ -553,7 +683,7 @@ function renderWeekEvent(ev) { return `
-
${esc(ev.title)}
+
${eventIconHtml(ev.icon, 'event-icon event-icon--compact')}${esc(ev.title)}
${formatTime(ev.start_datetime)}${ev.end_datetime ? '–' + formatTime(ev.end_datetime) : ''}
`; @@ -593,7 +723,7 @@ function renderDayView(container) { ${allday.map((ev) => `
${esc(ev.title)}
`).join('')} + title="${esc(ev.title)}${ev.cal_name ? ' · ' + ev.cal_name : ''}">${eventIconHtml(ev.icon, 'event-icon event-icon--compact')}${esc(ev.title)}`).join('')} ` : ''}
@@ -689,7 +819,7 @@ function renderAgendaEvent(ev) {
-
${esc(ev.title)}${(ev.recurrence_rule || ev.is_recurring_instance) ? ' ' : ''}
+
${eventIconHtml(ev.icon)}${esc(ev.title)}${(ev.recurrence_rule || ev.is_recurring_instance) ? ' ' : ''}
${timeStr} ${ev.location ? `📍 ${esc(fmtLocation(ev.location))}` : ''} @@ -724,7 +854,7 @@ function showEventPopup(ev, anchor) { const displayColor = ev.cal_color || ev.color; popup.innerHTML = `
-
${esc(ev.title)}
+
${eventIconHtml(ev.icon)}${esc(ev.title)}
${ev.cal_name ? `
${esc(ev.cal_name)}
` : ''}
${timeStr}
@@ -811,20 +941,53 @@ const REMINDER_OFFSETS = () => [ { value: '15', label: t('reminders.offset15min') }, { value: '60', label: t('reminders.offset1hour') }, { value: '1440', label: t('reminders.offset1day') }, + { value: '2880', label: t('reminders.offset2days') }, + { value: '10080', label: t('reminders.offset1week') }, + { value: '20160', label: t('reminders.offset2weeks') }, + { value: 'custom', label: t('reminders.offsetCustom') }, ]; function reminderOffsetFromEvent(event, reminder) { if (!reminder || !event?.start_datetime) return ''; const remindMs = new Date(reminder.remind_at).getTime(); - const startMs = new Date(event.start_datetime).getTime(); + const startMs = new Date(reminderStartValue(event.start_datetime)).getTime(); const diffMin = Math.round((startMs - remindMs) / 60000); - const opts = [0, 15, 60, 1440]; + const opts = [0, 15, 60, 1440, 2880, 10080, 20160]; const match = opts.find((o) => o === diffMin); - return match !== undefined ? String(match) : ''; + return match !== undefined ? String(match) : 'custom'; +} + +function customReminderFromEvent(event, reminder) { + const fallback = { amount: 1, unit: 'days' }; + if (!reminder || !event?.start_datetime) return fallback; + const diffMin = Math.max(0, Math.round( + (new Date(reminderStartValue(event.start_datetime)).getTime() - new Date(reminder.remind_at).getTime()) / 60000 + )); + if (diffMin % 10080 === 0 && diffMin >= 10080) return { amount: diffMin / 10080, unit: 'weeks' }; + if (diffMin % 1440 === 0 && diffMin >= 1440) return { amount: diffMin / 1440, unit: 'days' }; + if (diffMin % 60 === 0 && diffMin >= 60) return { amount: diffMin / 60, unit: 'hours' }; + return { amount: Math.max(diffMin, 1), unit: 'minutes' }; +} + +function customReminderMinutes(amount, unit) { + const value = Math.max(parseInt(amount, 10) || 1, 1); + if (unit === 'weeks') return value * 10080; + if (unit === 'days') return value * 1440; + if (unit === 'hours') return value * 60; + return value; +} + +function reminderStartValue(startDatetime) { + return startDatetime?.includes('T') ? startDatetime : `${startDatetime}T09:00`; +} + +function toLocalDateTimeString(date) { + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } function renderCalendarReminderSection(reminder = null, event = null) { const currentOffset = event ? reminderOffsetFromEvent(event, reminder) : ''; + const custom = customReminderFromEvent(event, reminder); return `
@@ -835,6 +998,21 @@ function renderCalendarReminderSection(reminder = null, event = null) { ).join('')}
+
`; } @@ -898,6 +1076,56 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) { }); if (isEdit && event?.all_day) { timeFields.style.display = 'none'; alldayFields.style.display = ''; } + bindDateInputs(panel); + + const iconInput = panel.querySelector('#modal-icon'); + const iconTrigger = panel.querySelector('#modal-icon-trigger'); + const iconGrid = panel.querySelector('#modal-icon-grid'); + const selectIcon = (icon) => { + const nextIcon = eventIconName(icon); + if (iconInput) iconInput.value = nextIcon; + if (iconTrigger) { + iconTrigger.dataset.icon = nextIcon; + const iconEl = iconTrigger.querySelector('[data-lucide]'); + iconEl?.setAttribute('data-lucide', nextIcon); + } + iconGrid?.querySelectorAll('.event-icon-picker__option').forEach((btn) => { + const active = btn.dataset.icon === nextIcon; + btn.classList.toggle('event-icon-picker__option--active', active); + btn.setAttribute('aria-checked', active ? 'true' : 'false'); + }); + if (window.lucide) lucide.createIcons(); + }; + + iconTrigger?.addEventListener('click', () => { + if (!iconGrid) return; + iconGrid.hidden = !iconGrid.hidden; + iconTrigger.setAttribute('aria-expanded', iconGrid.hidden ? 'false' : 'true'); + }); + iconGrid?.addEventListener('click', (e) => { + const btn = e.target.closest('.event-icon-picker__option'); + if (!btn) return; + selectIcon(btn.dataset.icon); + iconGrid.hidden = true; + iconTrigger?.setAttribute('aria-expanded', 'false'); + iconTrigger?.focus(); + }); + document.addEventListener('click', function closeIconPicker(e) { + if (!panel.isConnected) { + document.removeEventListener('click', closeIconPicker); + return; + } + if (iconGrid?.hidden || iconGrid?.contains(e.target) || iconTrigger?.contains(e.target)) return; + iconGrid.hidden = true; + iconTrigger?.setAttribute('aria-expanded', 'false'); + }); + + const reminderOffset = panel.querySelector('#modal-reminder-offset'); + const reminderCustom = panel.querySelector('#modal-reminder-custom'); + reminderOffset?.addEventListener('change', () => { + if (reminderCustom) reminderCustom.hidden = reminderOffset.value !== 'custom'; + }); + panel.querySelector('#modal-cancel').addEventListener('click', closeModal); panel.querySelector('#modal-delete')?.addEventListener('click', async () => { @@ -906,6 +1134,7 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) { }); panel.querySelector('#modal-save').addEventListener('click', () => saveEvent(panel, mode, event?.id, reminder)); + if (window.lucide) lucide.createIcons(); }, }); } @@ -920,6 +1149,18 @@ function buildEventModalContent({ mode, event, date, reminder = null }) { const endDate = isEdit && event.end_datetime ? localDate(event.end_datetime) : startDate; const endTime = isEdit && event.end_datetime && event.end_datetime.length > 10 ? localTime(event.end_datetime) : '10:00'; + const selectedIcon = eventIconName(isEdit ? event.icon : 'calendar'); + const iconButtons = EVENT_ICONS.map((icon) => + `` + ).join(''); const userOpts = [ ``, @@ -929,10 +1170,28 @@ function buildEventModalContent({ mode, event, date, reminder = null }) { ].join(''); return ` -
- - +
+
+ + + +
+
+ + +
+
+
@@ -947,7 +1206,7 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {