From 1d1d2291e5a7d7251aebbef13d4a00e80acfc412 Mon Sep 17 00:00:00 2001 From: Rafael Foster Date: Mon, 27 Apr 2026 21:38:06 -0300 Subject: [PATCH 1/3] Add calendar event icons and flexible date inputs --- public/i18n.js | 50 +++++++++ public/locales/ar.json | 18 +++- public/locales/de.json | 38 ++++--- public/locales/el.json | 18 +++- public/locales/en.json | 18 +++- public/locales/es.json | 18 +++- public/locales/fr.json | 18 +++- public/locales/hi.json | 18 +++- public/locales/it.json | 18 +++- public/locales/ja.json | 18 +++- public/locales/pt.json | 18 +++- public/locales/ru.json | 18 +++- public/locales/sv.json | 18 +++- public/locales/tr.json | 18 +++- public/locales/uk.json | 18 +++- public/locales/zh.json | 18 +++- public/pages/birthdays.js | 16 ++- public/pages/budget.js | 17 ++- public/pages/calendar.js | 200 +++++++++++++++++++++++++++++------ public/pages/meals.js | 19 +++- public/pages/tasks.js | 30 ++++-- public/rrule-ui.js | 17 ++- public/styles/calendar.css | 51 +++++++++ public/sw.js | 8 +- server/db-schema-test.js | 5 + server/db.js | 7 ++ server/routes/calendar.js | 25 ++++- server/services/birthdays.js | 9 +- test-calendar.js | 5 + 29 files changed, 625 insertions(+), 124 deletions(-) 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..86517da 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": "Amount", + "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..2adff64 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": "Amount", + "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..fc3cb6a 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": "Amount", + "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..0a0ab1d 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": "Amount", + "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..95164a3 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": "Amount", + "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..5f38fb0 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": "Amount", + "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..f316596 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": "Amount", + "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..dbda8e5 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": "Amount", + "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..82e226f 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": "Quantidade", + "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..4590170 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": "Amount", + "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..b16347b 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": "Amount", + "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..1d3352e 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": "Amount", + "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..a124847 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": "Amount", + "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..597fb3e 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": "Amount", + "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..e61a478 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,29 @@ const EVENT_COLOR_NAMES = () => ({ '#30B0C7': t('calendar.colorCyan'), }); +const EVENT_ICONS = [ + { value: 'calendar', label: 'Calendar' }, + { value: 'tooth', label: 'Dentist' }, + { value: 'stethoscope', label: 'Doctor' }, + { value: 'heart-pulse', label: 'Health' }, + { value: 'briefcase', label: 'Work' }, + { value: 'plane', label: 'Travel' }, + { value: 'utensils', label: 'Meal' }, + { value: 'cake', label: 'Birthday' }, + { value: 'car', label: 'Car' }, + { value: 'graduation-cap', label: 'School' }, + { value: 'dumbbell', label: 'Sports' }, + { value: 'home', label: 'Home' }, + { value: 'shopping-bag', label: 'Shopping' }, + { value: 'music', label: 'Music' }, + { value: 'party-popper', label: 'Party' }, + { value: 'paw-print', label: 'Pet' }, + { value: 'scissors', label: 'Haircut' }, + { value: 'book-open', label: 'Reading' }, + { value: 'users', label: 'Family' }, + { value: 'bell', label: 'Reminder' }, +]; + const HOUR_HEIGHT = 56; // px pro Stunde in Wochen-/Tagesansicht /** @@ -136,14 +159,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 +175,27 @@ function formatDateTime(datetimeStr) { return time ? `${formatDate(date)} ${time} ${t('calendar.timeSuffix')}`.trimEnd() : formatDate(date); } +function eventIconName(icon) { + return EVENT_ICONS.some((item) => item.value === icon) ? icon : '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 +400,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 +475,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 +524,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 +596,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 +636,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 +732,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 +767,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 +854,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 +911,21 @@ function renderCalendarReminderSection(reminder = null, event = null) { ).join('')}
+
`; } @@ -898,6 +989,14 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) { }); if (isEdit && event?.all_day) { timeFields.style.display = 'none'; alldayFields.style.display = ''; } + bindDateInputs(panel); + + 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 +1005,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 +1020,10 @@ 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 iconOpts = EVENT_ICONS.map((icon) => + `` + ).join(''); const userOpts = [ ``, @@ -929,10 +1033,16 @@ function buildEventModalContent({ mode, event, date, reminder = null }) { ].join(''); return ` -
- - +
@@ -947,7 +1057,7 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {