Add calendar event icons and flexible date inputs

This commit is contained in:
Rafael Foster
2026-04-27 21:38:06 -03:00
parent 2ef3e6f004
commit 1d1d2291e5
29 changed files with 625 additions and 124 deletions
+50
View File
@@ -89,6 +89,10 @@ function getDateFormatPreference() {
return ['mdy', 'dmy', 'ymd'].includes(stored) ? stored : DEFAULT_DATE_FORMAT; return ['mdy', 'dmy', 'ymd'].includes(stored) ? stored : DEFAULT_DATE_FORMAT;
} }
export function getDateFormat() {
return getDateFormatPreference();
}
function formatDateParts(date, useUtc = false) { function formatDateParts(date, useUtc = false) {
const d = date instanceof Date ? date : new Date(date); const d = date instanceof Date ? date : new Date(date);
if (isNaN(d.getTime())) return ''; if (isNaN(d.getTime())) return '';
@@ -121,6 +125,52 @@ export function formatDate(date) {
return formatDateParts(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 */ /** Uhrzeit locale-aware formatieren */
export function formatTime(date) { export function formatTime(date) {
if (date == null) return ''; if (date == null) return '';
+14 -2
View File
@@ -349,7 +349,9 @@
"ics": { "ics": {
"reset": "إعادة التعيين للأصل", "reset": "إعادة التعيين للأصل",
"resetToast": "تم إعادة تعيين التغييرات." "resetToast": "تم إعادة تعيين التغييرات."
} },
"iconLabel": "Icon",
"invalidDate": "Use a valid date in the selected date format."
}, },
"notes": { "notes": {
"title": "لوحة الملاحظات", "title": "لوحة الملاحظات",
@@ -856,7 +858,17 @@
"notificationEnable": "تفعيل الإشعارات", "notificationEnable": "تفعيل الإشعارات",
"notificationEnabled": "الإشعارات نشطة", "notificationEnabled": "الإشعارات نشطة",
"notificationDenied": "الإشعارات محظورة", "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": { "onboarding": {
"step1Title": "Welcome to Oikos", "step1Title": "Welcome to Oikos",
+14 -2
View File
@@ -366,7 +366,9 @@
"ics": { "ics": {
"reset": "Auf Original zurücksetzen", "reset": "Auf Original zurücksetzen",
"resetToast": "Änderungen zurückgesetzt." "resetToast": "Änderungen zurückgesetzt."
} },
"iconLabel": "Icon",
"invalidDate": "Bitte ein gültiges Datum im ausgewählten Format verwenden."
}, },
"notes": { "notes": {
"title": "Notizen", "title": "Notizen",
@@ -823,7 +825,17 @@
"notificationDenied": "Benachrichtigungen blockiert", "notificationDenied": "Benachrichtigungen blockiert",
"notificationHint": "Erhalte Benachrichtigungen auch wenn die App geöffnet ist.", "notificationHint": "Erhalte Benachrichtigungen auch wenn die App geöffnet ist.",
"pendingBadgeTitle": "{{count}} fällige Erinnerung", "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": { "birthdays": {
"title": "Geburtstage", "title": "Geburtstage",
+14 -2
View File
@@ -349,7 +349,9 @@
"ics": { "ics": {
"reset": "Επαναφορά στο αρχικό", "reset": "Επαναφορά στο αρχικό",
"resetToast": "Οι αλλαγές επαναφέρθηκαν." "resetToast": "Οι αλλαγές επαναφέρθηκαν."
} },
"iconLabel": "Icon",
"invalidDate": "Use a valid date in the selected date format."
}, },
"notes": { "notes": {
"title": "Σημειώσεις", "title": "Σημειώσεις",
@@ -856,7 +858,17 @@
"notificationEnable": "Ενεργοποίηση ειδοποιήσεων", "notificationEnable": "Ενεργοποίηση ειδοποιήσεων",
"notificationEnabled": "Ειδοποιήσεις ενεργές", "notificationEnabled": "Ειδοποιήσεις ενεργές",
"notificationDenied": "Ειδοποιήσεις αποκλεισμένες", "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": { "onboarding": {
"step1Title": "Welcome to Oikos", "step1Title": "Welcome to Oikos",
+14 -2
View File
@@ -349,7 +349,9 @@
"ics": { "ics": {
"reset": "Reset to original", "reset": "Reset to original",
"resetToast": "Changes reset." "resetToast": "Changes reset."
} },
"iconLabel": "Icon",
"invalidDate": "Use a valid date in the selected date format."
}, },
"notes": { "notes": {
"title": "Board", "title": "Board",
@@ -798,7 +800,17 @@
"notificationDenied": "Notifications blocked", "notificationDenied": "Notifications blocked",
"notificationHint": "Receive notifications while the app is open.", "notificationHint": "Receive notifications while the app is open.",
"pendingBadgeTitle": "{{count}} reminder due", "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": { "birthdays": {
"title": "Birthdays", "title": "Birthdays",
+14 -2
View File
@@ -349,7 +349,9 @@
"ics": { "ics": {
"reset": "Restaurar original", "reset": "Restaurar original",
"resetToast": "Cambios restablecidos." "resetToast": "Cambios restablecidos."
} },
"iconLabel": "Icon",
"invalidDate": "Use a valid date in the selected date format."
}, },
"notes": { "notes": {
"title": "Notas", "title": "Notas",
@@ -856,7 +858,17 @@
"notificationEnable": "Activar notificaciones", "notificationEnable": "Activar notificaciones",
"notificationEnabled": "Notificaciones activas", "notificationEnabled": "Notificaciones activas",
"notificationDenied": "Notificaciones bloqueadas", "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": { "onboarding": {
"step1Title": "Welcome to Oikos", "step1Title": "Welcome to Oikos",
+14 -2
View File
@@ -349,7 +349,9 @@
"ics": { "ics": {
"reset": "Réinitialiser", "reset": "Réinitialiser",
"resetToast": "Modifications annulées." "resetToast": "Modifications annulées."
} },
"iconLabel": "Icon",
"invalidDate": "Use a valid date in the selected date format."
}, },
"notes": { "notes": {
"title": "Notes", "title": "Notes",
@@ -856,7 +858,17 @@
"notificationEnable": "Activer les notifications", "notificationEnable": "Activer les notifications",
"notificationEnabled": "Notifications actives", "notificationEnabled": "Notifications actives",
"notificationDenied": "Notifications bloquées", "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": { "onboarding": {
"step1Title": "Welcome to Oikos", "step1Title": "Welcome to Oikos",
+14 -2
View File
@@ -349,7 +349,9 @@
"ics": { "ics": {
"reset": "मूल पर वापस जाएं", "reset": "मूल पर वापस जाएं",
"resetToast": "परिवर्तन रीसेट हो गए।" "resetToast": "परिवर्तन रीसेट हो गए।"
} },
"iconLabel": "Icon",
"invalidDate": "Use a valid date in the selected date format."
}, },
"notes": { "notes": {
"title": "नोट बोर्ड", "title": "नोट बोर्ड",
@@ -856,7 +858,17 @@
"notificationEnable": "सूचनाएं सक्षम करें", "notificationEnable": "सूचनाएं सक्षम करें",
"notificationEnabled": "सूचनाएं सक्रिय", "notificationEnabled": "सूचनाएं सक्रिय",
"notificationDenied": "सूचनाएं अवरुद्ध", "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": { "onboarding": {
"step1Title": "Welcome to Oikos", "step1Title": "Welcome to Oikos",
+14 -2
View File
@@ -349,7 +349,9 @@
"ics": { "ics": {
"reset": "Ripristina originale", "reset": "Ripristina originale",
"resetToast": "Modifiche ripristinate." "resetToast": "Modifiche ripristinate."
} },
"iconLabel": "Icon",
"invalidDate": "Use a valid date in the selected date format."
}, },
"notes": { "notes": {
"title": "Bacheca", "title": "Bacheca",
@@ -856,7 +858,17 @@
"notificationEnable": "Attiva notifiche", "notificationEnable": "Attiva notifiche",
"notificationEnabled": "Notifiche attive", "notificationEnabled": "Notifiche attive",
"notificationDenied": "Notifiche bloccate", "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": { "onboarding": {
"step1Title": "Welcome to Oikos", "step1Title": "Welcome to Oikos",
+14 -2
View File
@@ -349,7 +349,9 @@
"ics": { "ics": {
"reset": "元に戻す", "reset": "元に戻す",
"resetToast": "変更がリセットされました。" "resetToast": "変更がリセットされました。"
} },
"iconLabel": "Icon",
"invalidDate": "Use a valid date in the selected date format."
}, },
"notes": { "notes": {
"title": "メモボード", "title": "メモボード",
@@ -856,7 +858,17 @@
"notificationEnable": "通知を有効にする", "notificationEnable": "通知を有効にする",
"notificationEnabled": "通知が有効", "notificationEnabled": "通知が有効",
"notificationDenied": "通知がブロックされています", "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": { "onboarding": {
"step1Title": "Welcome to Oikos", "step1Title": "Welcome to Oikos",
+14 -2
View File
@@ -349,7 +349,9 @@
"ics": { "ics": {
"reset": "Restaurar original", "reset": "Restaurar original",
"resetToast": "Alterações restauradas." "resetToast": "Alterações restauradas."
} },
"iconLabel": "Ícone",
"invalidDate": "Use uma data válida no formato selecionado."
}, },
"notes": { "notes": {
"title": "Quadro de notas", "title": "Quadro de notas",
@@ -857,7 +859,17 @@
"notificationEnable": "Ativar notificações", "notificationEnable": "Ativar notificações",
"notificationEnabled": "Notificações ativas", "notificationEnabled": "Notificações ativas",
"notificationDenied": "Notificações bloqueadas", "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": { "onboarding": {
"step1Title": "Welcome to Oikos", "step1Title": "Welcome to Oikos",
+14 -2
View File
@@ -349,7 +349,9 @@
"ics": { "ics": {
"reset": "Сбросить к исходному", "reset": "Сбросить к исходному",
"resetToast": "Изменения сброшены." "resetToast": "Изменения сброшены."
} },
"iconLabel": "Icon",
"invalidDate": "Use a valid date in the selected date format."
}, },
"notes": { "notes": {
"title": "Заметки", "title": "Заметки",
@@ -856,7 +858,17 @@
"notificationEnable": "Включить уведомления", "notificationEnable": "Включить уведомления",
"notificationEnabled": "Уведомления активны", "notificationEnabled": "Уведомления активны",
"notificationDenied": "Уведомления заблокированы", "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": { "onboarding": {
"step1Title": "Welcome to Oikos", "step1Title": "Welcome to Oikos",
+14 -2
View File
@@ -349,7 +349,9 @@
"ics": { "ics": {
"reset": "Återställ till original", "reset": "Återställ till original",
"resetToast": "Ändringar återställda." "resetToast": "Ändringar återställda."
} },
"iconLabel": "Icon",
"invalidDate": "Use a valid date in the selected date format."
}, },
"notes": { "notes": {
"title": "Anteckningar", "title": "Anteckningar",
@@ -856,7 +858,17 @@
"notificationEnable": "Aktivera notiser", "notificationEnable": "Aktivera notiser",
"notificationEnabled": "Notiser aktiva", "notificationEnabled": "Notiser aktiva",
"notificationDenied": "Notiser blockerade", "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": { "onboarding": {
"step1Title": "Welcome to Oikos", "step1Title": "Welcome to Oikos",
+14 -2
View File
@@ -349,7 +349,9 @@
"ics": { "ics": {
"reset": "Orijinale sıfırla", "reset": "Orijinale sıfırla",
"resetToast": "Değişiklikler sıfırlandı." "resetToast": "Değişiklikler sıfırlandı."
} },
"iconLabel": "Icon",
"invalidDate": "Use a valid date in the selected date format."
}, },
"notes": { "notes": {
"title": "Notlar", "title": "Notlar",
@@ -856,7 +858,17 @@
"notificationEnable": "Bildirimleri etkinleştir", "notificationEnable": "Bildirimleri etkinleştir",
"notificationEnabled": "Bildirimler etkin", "notificationEnabled": "Bildirimler etkin",
"notificationDenied": "Bildirimler engellendi", "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": { "onboarding": {
"step1Title": "Welcome to Oikos", "step1Title": "Welcome to Oikos",
+14 -2
View File
@@ -349,7 +349,9 @@
"ics": { "ics": {
"reset": "Скинути до оригіналу", "reset": "Скинути до оригіналу",
"resetToast": "Зміни скинуто." "resetToast": "Зміни скинуто."
} },
"iconLabel": "Icon",
"invalidDate": "Use a valid date in the selected date format."
}, },
"notes": { "notes": {
"title": "Нотатки", "title": "Нотатки",
@@ -798,7 +800,17 @@
"notificationDenied": "Сповіщення заблоковано", "notificationDenied": "Сповіщення заблоковано",
"notificationHint": "Отримуйте сповіщення, поки додаток відкрито.", "notificationHint": "Отримуйте сповіщення, поки додаток відкрито.",
"pendingBadgeTitle": "{{count}} нагадування", "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": { "recipes": {
"title": "Recipes", "title": "Recipes",
+14 -2
View File
@@ -349,7 +349,9 @@
"ics": { "ics": {
"reset": "重置为原始", "reset": "重置为原始",
"resetToast": "更改已重置。" "resetToast": "更改已重置。"
} },
"iconLabel": "Icon",
"invalidDate": "Use a valid date in the selected date format."
}, },
"notes": { "notes": {
"title": "便签板", "title": "便签板",
@@ -856,7 +858,17 @@
"notificationEnable": "启用通知", "notificationEnable": "启用通知",
"notificationEnabled": "通知已启用", "notificationEnabled": "通知已启用",
"notificationDenied": "通知已被阻止", "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": { "onboarding": {
"step1Title": "Welcome to Oikos", "step1Title": "Welcome to Oikos",
+12 -4
View File
@@ -1,7 +1,7 @@
import { api } from '/api.js'; import { api } from '/api.js';
import { openModal as openSharedModal, closeModal, confirmModal } from '/components/modal.js'; import { openModal as openSharedModal, closeModal, confirmModal } from '/components/modal.js';
import { stagger, deleteWithUndo } from '/utils/ux.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'; import { esc } from '/utils/html.js';
let state = { let state = {
@@ -282,7 +282,7 @@ function openBirthdayModal({ mode, birthday = null }) {
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="bd-birth-date">${t('birthdays.birthDateLabel')}</label> <label class="form-label" for="bd-birth-date">${t('birthdays.birthDateLabel')}</label>
<input class="form-input" id="bd-birth-date" type="date" value="${esc(birthday?.birth_date || '')}"> <input class="form-input js-date-input" id="bd-birth-date" type="text" value="${esc(formatDateInput(birthday?.birth_date))}" placeholder="${dateInputPlaceholder()}" inputmode="numeric">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="bd-photo">${t('birthdays.photoLabel')}</label> <label class="form-label" for="bd-photo">${t('birthdays.photoLabel')}</label>
@@ -315,6 +315,12 @@ function openBirthdayModal({ mode, birthday = null }) {
preview.insertAdjacentHTML('beforeend', birthdayPreviewHtml(nameInput.value.trim(), photoData)); preview.insertAdjacentHTML('beforeend', birthdayPreviewHtml(nameInput.value.trim(), photoData));
}; };
nameInput.addEventListener('input', renderPreview); 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) => { panel.querySelector('#bd-photo').addEventListener('change', async (e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
@@ -337,14 +343,16 @@ function openBirthdayModal({ mode, birthday = null }) {
}); });
panel.querySelector('#bd-save').addEventListener('click', async () => { panel.querySelector('#bd-save').addEventListener('click', async () => {
const saveBtn = panel.querySelector('#bd-save'); const saveBtn = panel.querySelector('#bd-save');
const birthDateRaw = panel.querySelector('#bd-birth-date').value;
const birthDate = parseDateInput(birthDateRaw);
const body = { const body = {
name: panel.querySelector('#bd-name').value.trim(), 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(), notes: panel.querySelector('#bd-notes').value.trim(),
photo_data: photoData, photo_data: photoData,
}; };
if (!body.name || !body.birth_date) { if (!body.name || !body.birth_date || !isDateInputValid(birthDateRaw)) {
window.oikos?.showToast(t('birthdays.requiredFields'), 'warning'); window.oikos?.showToast(t('birthdays.requiredFields'), 'warning');
return; return;
} }
+12 -5
View File
@@ -8,7 +8,7 @@
import { api } from '/api.js'; import { api } from '/api.js';
import { openModal as openSharedModal, closeModal } from '/components/modal.js'; import { openModal as openSharedModal, closeModal } from '/components/modal.js';
import { stagger, vibrate } from '/utils/ux.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'; import { esc } from '/utils/html.js';
// -------------------------------------------------------- // --------------------------------------------------------
@@ -494,8 +494,8 @@ function openBudgetModal({ mode, entry = null }) {
<div class="form-group"> <div class="form-group">
<label class="form-label" for="bm-date">${t('budget.dateLabel')}</label> <label class="form-label" for="bm-date">${t('budget.dateLabel')}</label>
<input type="date" class="form-input" id="bm-date" <input type="text" class="form-input js-date-input" id="bm-date"
value="${isEdit ? entry.date : today}"> value="${formatDateInput(isEdit ? entry.date : today)}" placeholder="${dateInputPlaceholder()}" inputmode="numeric">
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -604,6 +604,12 @@ function openBudgetModal({ mode, entry = null }) {
panel.querySelector('#bm-category').addEventListener('change', () => updateSubcategoryOptions()); panel.querySelector('#bm-category').addEventListener('change', () => updateSubcategoryOptions());
panel.querySelector('#bm-add-category').addEventListener('click', addCategory); panel.querySelector('#bm-add-category').addEventListener('click', addCategory);
panel.querySelector('#bm-add-subcategory').addEventListener('click', addSubcategory); 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); 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 absVal = parseFloat(panel.querySelector('#bm-amount').value);
const category = panel.querySelector('#bm-category').value; const category = panel.querySelector('#bm-category').value;
const subcategory = currentType === 'expense' ? panel.querySelector('#bm-subcategory').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; const recurring = panel.querySelector('#bm-recurring').checked ? 1 : 0;
if (!title) { window.oikos?.showToast(t('common.titleRequired'), '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 (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; const amount = currentType === 'expense' ? -absVal : absVal;
+161 -31
View File
@@ -8,7 +8,7 @@ import { api } from '/api.js';
import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js'; import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js';
import { openModal as openSharedModal, closeModal } from '/components/modal.js'; import { openModal as openSharedModal, closeModal } from '/components/modal.js';
import { stagger } from '/utils/ux.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 { esc, fmtLocation } from '/utils/html.js';
import { refresh as refreshReminders } from '/reminders.js'; import { refresh as refreshReminders } from '/reminders.js';
@@ -59,6 +59,29 @@ const EVENT_COLOR_NAMES = () => ({
'#30B0C7': t('calendar.colorCyan'), '#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 const HOUR_HEIGHT = 56; // px pro Stunde in Wochen-/Tagesansicht
/** /**
@@ -136,14 +159,12 @@ function getMondayOf(dateStr) {
} }
function formatDate(dateStr, { long = false, weekday = false } = {}) { 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) { if (weekday) {
const d = new Date(dateStr + 'T00:00:00');
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 `${wd}, ${formatPreferredDate(dateStr)}`;
} }
return `${day}. ${mon} ${d.getFullYear()}`; return formatPreferredDate(dateStr);
} }
function formatDateTime(datetimeStr) { function formatDateTime(datetimeStr) {
@@ -154,6 +175,27 @@ function formatDateTime(datetimeStr) {
return time ? `${formatDate(date)} ${time} ${t('calendar.timeSuffix')}`.trimEnd() : formatDate(date); 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 `<i class="${className}" data-lucide="${eventIconName(icon)}" aria-hidden="true"></i>`;
}
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) { function getMonthRange(dateStr) {
const d = new Date(dateStr + 'T00:00:00'); const d = new Date(dateStr + 'T00:00:00');
const year = d.getFullYear(); const year = d.getFullYear();
@@ -358,6 +400,7 @@ function renderView() {
if (state.view === 'week') renderWeekView(body); if (state.view === 'week') renderWeekView(body);
if (state.view === 'day') renderDayView(body); if (state.view === 'day') renderDayView(body);
if (state.view === 'agenda') renderAgendaView(body); if (state.view === 'agenda') renderAgendaView(body);
if (window.lucide) lucide.createIcons();
} }
// -------------------------------------------------------- // --------------------------------------------------------
@@ -432,7 +475,7 @@ function renderMonthDay(date, inMonth) {
data-id="${ev.id}" data-id="${ev.id}"
style="background-color:${esc(bg)};${fg ? `color:${fg};` : ''}" style="background-color:${esc(bg)};${fg ? `color:${fg};` : ''}"
title="${esc(ev.title)}${ev.cal_name ? ' · ' + ev.cal_name : ''}" title="${esc(ev.title)}${ev.cal_name ? ' · ' + ev.cal_name : ''}"
>${esc(ev.title)}</div> >${eventIconHtml(ev.icon, 'event-icon event-icon--compact')}<span>${esc(ev.title)}</span></div>
`; `;
}).join(''); }).join('');
@@ -481,7 +524,7 @@ function renderWeekView(container) {
${alldayEvs[i].map((ev) => ` ${alldayEvs[i].map((ev) => `
<div class="allday-event" data-id="${ev.id}" <div class="allday-event" data-id="${ev.id}"
style="${ev.cal_color || ev.color ? `background-color:${esc(ev.cal_color || ev.color)};` : ''}${getContrastColor(ev.cal_color || ev.color) ? `color:${getContrastColor(ev.cal_color || ev.color)};` : ''}" style="${ev.cal_color || ev.color ? `background-color:${esc(ev.cal_color || ev.color)};` : ''}${getContrastColor(ev.cal_color || ev.color) ? `color:${getContrastColor(ev.cal_color || ev.color)};` : ''}"
title="${esc(ev.title)}${ev.cal_name ? ' · ' + ev.cal_name : ''}">${esc(ev.title)}</div> title="${esc(ev.title)}${ev.cal_name ? ' · ' + ev.cal_name : ''}">${eventIconHtml(ev.icon, 'event-icon event-icon--compact')}<span>${esc(ev.title)}</span></div>
`).join('')} `).join('')}
</div> </div>
`).join('')} `).join('')}
@@ -553,7 +596,7 @@ function renderWeekEvent(ev) {
return ` return `
<div class="week-event" data-id="${ev.id}" <div class="week-event" data-id="${ev.id}"
style="top:${top}px;height:${height}px;${ev.cal_color || ev.color ? `background-color:${esc(ev.cal_color || ev.color)};` : ''}${getContrastColor(ev.cal_color || ev.color) ? `color:${getContrastColor(ev.cal_color || ev.color)};` : ''}"> style="top:${top}px;height:${height}px;${ev.cal_color || ev.color ? `background-color:${esc(ev.cal_color || ev.color)};` : ''}${getContrastColor(ev.cal_color || ev.color) ? `color:${getContrastColor(ev.cal_color || ev.color)};` : ''}">
<div class="week-event__title">${esc(ev.title)}</div> <div class="week-event__title">${eventIconHtml(ev.icon, 'event-icon event-icon--compact')}<span>${esc(ev.title)}</span></div>
<div class="week-event__time">${formatTime(ev.start_datetime)}${ev.end_datetime ? '' + formatTime(ev.end_datetime) : ''}</div> <div class="week-event__time">${formatTime(ev.start_datetime)}${ev.end_datetime ? '' + formatTime(ev.end_datetime) : ''}</div>
</div> </div>
`; `;
@@ -593,7 +636,7 @@ function renderDayView(container) {
${allday.map((ev) => ` ${allday.map((ev) => `
<div class="allday-event" data-id="${ev.id}" <div class="allday-event" data-id="${ev.id}"
style="${ev.cal_color || ev.color ? `background-color:${esc(ev.cal_color || ev.color)};` : ''}${getContrastColor(ev.cal_color || ev.color) ? `color:${getContrastColor(ev.cal_color || ev.color)};` : ''}" style="${ev.cal_color || ev.color ? `background-color:${esc(ev.cal_color || ev.color)};` : ''}${getContrastColor(ev.cal_color || ev.color) ? `color:${getContrastColor(ev.cal_color || ev.color)};` : ''}"
title="${esc(ev.title)}${ev.cal_name ? ' · ' + ev.cal_name : ''}">${esc(ev.title)}</div>`).join('')} title="${esc(ev.title)}${ev.cal_name ? ' · ' + ev.cal_name : ''}">${eventIconHtml(ev.icon, 'event-icon event-icon--compact')}<span>${esc(ev.title)}</span></div>`).join('')}
</div> </div>
</div>` : ''} </div>` : ''}
<div class="day-view__scroll" id="day-scroll"> <div class="day-view__scroll" id="day-scroll">
@@ -689,7 +732,7 @@ function renderAgendaEvent(ev) {
<div class="agenda-event" data-id="${ev.id}"> <div class="agenda-event" data-id="${ev.id}">
<div class="agenda-event__color" style="background-color:${esc(displayColor)};"></div> <div class="agenda-event__color" style="background-color:${esc(displayColor)};"></div>
<div class="agenda-event__body"> <div class="agenda-event__body">
<div class="agenda-event__title">${esc(ev.title)}${(ev.recurrence_rule || ev.is_recurring_instance) ? ' <i data-lucide="repeat" style="width:12px;height:12px;display:inline;vertical-align:middle;opacity:0.5" aria-hidden="true"></i>' : ''}</div> <div class="agenda-event__title">${eventIconHtml(ev.icon)}<span>${esc(ev.title)}</span>${(ev.recurrence_rule || ev.is_recurring_instance) ? ' <i data-lucide="repeat" style="width:12px;height:12px;display:inline;vertical-align:middle;opacity:0.5" aria-hidden="true"></i>' : ''}</div>
<div class="agenda-event__meta"> <div class="agenda-event__meta">
<span>${timeStr}</span> <span>${timeStr}</span>
${ev.location ? `<span>📍 ${esc(fmtLocation(ev.location))}</span>` : ''} ${ev.location ? `<span>📍 ${esc(fmtLocation(ev.location))}</span>` : ''}
@@ -724,7 +767,7 @@ function showEventPopup(ev, anchor) {
const displayColor = ev.cal_color || ev.color; const displayColor = ev.cal_color || ev.color;
popup.innerHTML = ` popup.innerHTML = `
<div class="event-popup__color-bar" style="background-color:${esc(displayColor)};"></div> <div class="event-popup__color-bar" style="background-color:${esc(displayColor)};"></div>
<div class="event-popup__title">${esc(ev.title)}</div> <div class="event-popup__title">${eventIconHtml(ev.icon)}<span>${esc(ev.title)}</span></div>
<div class="event-popup__meta"> <div class="event-popup__meta">
${ev.cal_name ? `<div><span class="event-cal-label" style="--cal-color:${esc(displayColor)}">${esc(ev.cal_name)}</span></div>` : ''} ${ev.cal_name ? `<div><span class="event-cal-label" style="--cal-color:${esc(displayColor)}">${esc(ev.cal_name)}</span></div>` : ''}
<div>${timeStr}</div> <div>${timeStr}</div>
@@ -811,20 +854,53 @@ const REMINDER_OFFSETS = () => [
{ value: '15', label: t('reminders.offset15min') }, { value: '15', label: t('reminders.offset15min') },
{ value: '60', label: t('reminders.offset1hour') }, { value: '60', label: t('reminders.offset1hour') },
{ value: '1440', label: t('reminders.offset1day') }, { 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) { function reminderOffsetFromEvent(event, reminder) {
if (!reminder || !event?.start_datetime) return ''; if (!reminder || !event?.start_datetime) return '';
const remindMs = new Date(reminder.remind_at).getTime(); 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 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); 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) { function renderCalendarReminderSection(reminder = null, event = null) {
const currentOffset = event ? reminderOffsetFromEvent(event, reminder) : ''; const currentOffset = event ? reminderOffsetFromEvent(event, reminder) : '';
const custom = customReminderFromEvent(event, reminder);
return ` return `
<div class="reminder-section"> <div class="reminder-section">
<div class="form-group" style="margin:0"> <div class="form-group" style="margin:0">
@@ -835,6 +911,21 @@ function renderCalendarReminderSection(reminder = null, event = null) {
).join('')} ).join('')}
</select> </select>
</div> </div>
<div class="modal-grid modal-grid--2 reminder-custom" id="modal-reminder-custom" ${currentOffset === 'custom' ? '' : 'hidden'}>
<div class="form-group" style="margin:0">
<label class="form-label" for="modal-reminder-custom-amount">${t('reminders.customAmountLabel')}</label>
<input class="form-input" type="number" id="modal-reminder-custom-amount" min="1" max="999" value="${custom.amount}">
</div>
<div class="form-group" style="margin:0">
<label class="form-label" for="modal-reminder-custom-unit">${t('reminders.customUnitLabel')}</label>
<select class="form-input" id="modal-reminder-custom-unit">
<option value="minutes" ${custom.unit === 'minutes' ? 'selected' : ''}>${t('reminders.customMinutes')}</option>
<option value="hours" ${custom.unit === 'hours' ? 'selected' : ''}>${t('reminders.customHours')}</option>
<option value="days" ${custom.unit === 'days' ? 'selected' : ''}>${t('reminders.customDays')}</option>
<option value="weeks" ${custom.unit === 'weeks' ? 'selected' : ''}>${t('reminders.customWeeks')}</option>
</select>
</div>
</div>
</div>`; </div>`;
} }
@@ -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 = ''; } 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-cancel').addEventListener('click', closeModal);
panel.querySelector('#modal-delete')?.addEventListener('click', async () => { 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)); 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 endDate = isEdit && event.end_datetime ? localDate(event.end_datetime) : startDate;
const endTime = isEdit && event.end_datetime && event.end_datetime.length > 10 const endTime = isEdit && event.end_datetime && event.end_datetime.length > 10
? localTime(event.end_datetime) : '10:00'; ? localTime(event.end_datetime) : '10:00';
const selectedIcon = eventIconName(isEdit ? event.icon : 'calendar');
const iconOpts = EVENT_ICONS.map((icon) =>
`<option value="${icon.value}" ${selectedIcon === icon.value ? 'selected' : ''}>${esc(icon.label)}</option>`
).join('');
const userOpts = [ const userOpts = [
`<option value="">${t('calendar.assignedNobody')}</option>`, `<option value="">${t('calendar.assignedNobody')}</option>`,
@@ -929,11 +1033,17 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
].join(''); ].join('');
return ` return `
<div class="modal-grid modal-grid--event-title">
<div class="form-group"> <div class="form-group">
<label class="form-label" for="modal-title">${t('calendar.titleLabel')}</label> <label class="form-label" for="modal-title">${t('calendar.titleLabel')}</label>
<input type="text" class="form-input" id="modal-title" <input type="text" class="form-input" id="modal-title"
placeholder="${t('calendar.titlePlaceholder')}" value="${esc(isEdit ? event.title : '')}"> placeholder="${t('calendar.titlePlaceholder')}" value="${esc(isEdit ? event.title : '')}">
</div> </div>
<div class="form-group">
<label class="form-label" for="modal-icon">${t('calendar.iconLabel')}</label>
<select class="form-input" id="modal-icon">${iconOpts}</select>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label class="toggle"> <label class="toggle">
@@ -947,7 +1057,7 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
<div class="modal-grid modal-grid--2"> <div class="modal-grid modal-grid--2">
<div class="form-group"> <div class="form-group">
<label class="form-label" for="modal-start-date">${t('calendar.startDateLabel')}</label> <label class="form-label" for="modal-start-date">${t('calendar.startDateLabel')}</label>
<input type="date" class="form-input" id="modal-start-date" value="${startDate}"> <input type="text" class="form-input js-date-input" id="modal-start-date" value="${formatDateInput(startDate)}" placeholder="${dateInputPlaceholder()}" inputmode="numeric">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="modal-start-time">${t('calendar.startTimeLabel')}</label> <label class="form-label" for="modal-start-time">${t('calendar.startTimeLabel')}</label>
@@ -957,7 +1067,7 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
<div class="modal-grid modal-grid--2"> <div class="modal-grid modal-grid--2">
<div class="form-group"> <div class="form-group">
<label class="form-label" for="modal-end-date">${t('calendar.endDateLabel')}</label> <label class="form-label" for="modal-end-date">${t('calendar.endDateLabel')}</label>
<input type="date" class="form-input" id="modal-end-date" value="${endDate}"> <input type="text" class="form-input js-date-input" id="modal-end-date" value="${formatDateInput(endDate)}" placeholder="${dateInputPlaceholder()}" inputmode="numeric">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="modal-end-time">${t('calendar.endTimeLabel')}</label> <label class="form-label" for="modal-end-time">${t('calendar.endTimeLabel')}</label>
@@ -970,11 +1080,11 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
<div class="modal-grid modal-grid--2"> <div class="modal-grid modal-grid--2">
<div class="form-group"> <div class="form-group">
<label class="form-label" for="modal-allday-start">${t('calendar.fromLabel')}</label> <label class="form-label" for="modal-allday-start">${t('calendar.fromLabel')}</label>
<input type="date" class="form-input" id="modal-allday-start" value="${startDate}"> <input type="text" class="form-input js-date-input" id="modal-allday-start" value="${formatDateInput(startDate)}" placeholder="${dateInputPlaceholder()}" inputmode="numeric">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="modal-allday-end">${t('calendar.toLabel')}</label> <label class="form-label" for="modal-allday-end">${t('calendar.toLabel')}</label>
<input type="date" class="form-input" id="modal-allday-end" value="${endDate}"> <input type="text" class="form-input js-date-input" id="modal-allday-end" value="${formatDateInput(endDate)}" placeholder="${dateInputPlaceholder()}" inputmode="numeric">
</div> </div>
</div> </div>
</div> </div>
@@ -1035,6 +1145,7 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null) {
const allday = overlay.querySelector('#modal-allday').checked; const allday = overlay.querySelector('#modal-allday').checked;
const color = overlay.querySelector('.color-swatch--active')?.dataset.color || EVENT_COLORS[0]; const color = overlay.querySelector('.color-swatch--active')?.dataset.color || EVENT_COLORS[0];
const icon = eventIconName(overlay.querySelector('#modal-icon')?.value);
const location = overlay.querySelector('#modal-location').value.trim() || null; const location = overlay.querySelector('#modal-location').value.trim() || null;
const assigned_to = overlay.querySelector('#modal-assigned').value || null; const assigned_to = overlay.querySelector('#modal-assigned').value || null;
const description = overlay.querySelector('#modal-description').value.trim() || null; const description = overlay.querySelector('#modal-description').value.trim() || null;
@@ -1042,18 +1153,27 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null) {
let start_datetime, end_datetime; let start_datetime, end_datetime;
if (allday) { if (allday) {
start_datetime = overlay.querySelector('#modal-allday-start')?.value start_datetime = readDateInput(overlay, '#modal-allday-start')
|| overlay.querySelector('#modal-start-date').value; || readDateInput(overlay, '#modal-start-date');
end_datetime = overlay.querySelector('#modal-allday-end')?.value end_datetime = readDateInput(overlay, '#modal-allday-end')
|| overlay.querySelector('#modal-end-date').value; || readDateInput(overlay, '#modal-end-date');
end_datetime = end_datetime || null; end_datetime = end_datetime || null;
} else { } else {
const sd = overlay.querySelector('#modal-start-date').value; const sd = readDateInput(overlay, '#modal-start-date');
const st = overlay.querySelector('#modal-start-time').value; const st = overlay.querySelector('#modal-start-time').value;
const ed = overlay.querySelector('#modal-end-date').value; const ed = readDateInput(overlay, '#modal-end-date');
const et = overlay.querySelector('#modal-end-time').value; const et = overlay.querySelector('#modal-end-time').value;
start_datetime = st ? `${sd}T${st}` : sd; start_datetime = st ? `${sd}T${st}` : sd;
end_datetime = et ? `${ed}T${et}` : (ed || null); end_datetime = ed ? (et ? `${ed}T${et}` : ed) : null;
}
const visibleDateFields = allday
? ['#modal-allday-start', '#modal-allday-end']
: ['#modal-start-date', '#modal-end-date'];
const hasInvalidDate = visibleDateFields.some((selector) => !isDateInputValid(overlay.querySelector(selector)?.value));
if (!start_datetime || hasInvalidDate) {
window.oikos?.showToast(t('calendar.invalidDate'), 'error');
return;
} }
saveBtn.disabled = true; saveBtn.disabled = true;
@@ -1061,10 +1181,16 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null) {
try { try {
const rrule = getRRuleValues(overlay, 'event'); const rrule = getRRuleValues(overlay, 'event');
if (!rrule.valid_until) {
window.oikos?.showToast(t('calendar.invalidDate'), 'error');
saveBtn.disabled = false;
saveBtn.textContent = mode === 'edit' ? t('common.save') : t('common.create');
return;
}
const body = { const body = {
title, description, start_datetime, end_datetime, title, description, start_datetime, end_datetime,
all_day: allday ? 1 : 0, all_day: allday ? 1 : 0,
location, color, assigned_to: assigned_to ? parseInt(assigned_to, 10) : null, location, color, icon, assigned_to: assigned_to ? parseInt(assigned_to, 10) : null,
recurrence_rule: rrule.recurrence_rule, recurrence_rule: rrule.recurrence_rule,
}; };
@@ -1086,9 +1212,14 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null) {
if (offsetVal !== '' && offsetVal !== undefined) { if (offsetVal !== '' && offsetVal !== undefined) {
// Remind-Zeitpunkt = start_datetime - offset (in Minuten) // Remind-Zeitpunkt = start_datetime - offset (in Minuten)
const startMs = new Date(start_datetime).getTime(); const startMs = new Date(reminderStartValue(start_datetime)).getTime();
const offsetMs = parseInt(offsetVal, 10) * 60000; const offsetMinutes = offsetVal === 'custom'
const remindAt = new Date(startMs - offsetMs).toISOString().slice(0, 16); ? customReminderMinutes(
overlay.querySelector('#modal-reminder-custom-amount')?.value,
overlay.querySelector('#modal-reminder-custom-unit')?.value
)
: parseInt(offsetVal, 10);
const remindAt = toLocalDateTimeString(new Date(startMs - offsetMinutes * 60000));
await api.post('/reminders', { entity_type: 'event', entity_id: savedEventId, remind_at: remindAt }); await api.post('/reminders', { entity_type: 'event', entity_id: savedEventId, remind_at: remindAt });
refreshReminders(); refreshReminders();
} else { } else {
@@ -1136,4 +1267,3 @@ async function deleteEvent(id) {
} }
}, 5000); }, 5000);
} }
+15 -4
View File
@@ -7,7 +7,7 @@
import { api } from '/api.js'; import { api } from '/api.js';
import { openModal as openSharedModal, closeModal as closeSharedModal, selectModal } from '/components/modal.js'; import { openModal as openSharedModal, closeModal as closeSharedModal, selectModal } from '/components/modal.js';
import { stagger } from '/utils/ux.js'; import { stagger } 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'; import { esc } from '/utils/html.js';
import { DEFAULT_CATEGORY_NAME, categoryLabel } from '/utils/shopping-categories.js'; import { DEFAULT_CATEGORY_NAME, categoryLabel } from '/utils/shopping-categories.js';
@@ -680,6 +680,12 @@ function openMealModal(opts) {
recipeSelect.value = String(presetRecipeId); recipeSelect.value = String(presetRecipeId);
applyRecipe(presetRecipeId); applyRecipe(presetRecipeId);
} }
panel.querySelectorAll('.js-date-input').forEach((input) => {
input.addEventListener('blur', () => {
const parsed = parseDateInput(input.value);
if (parsed) input.value = formatDateInput(parsed);
});
});
addIngBtn.addEventListener('click', () => { addIngBtn.addEventListener('click', () => {
const tmp = document.createElement('div'); const tmp = document.createElement('div');
@@ -750,7 +756,7 @@ function buildModalContent({ mode, date, mealType, meal }) {
<div class="modal-grid modal-grid--2"> <div class="modal-grid modal-grid--2">
<div class="form-group"> <div class="form-group">
<label class="form-label" for="modal-date">${t('meals.dateLabel')}</label> <label class="form-label" for="modal-date">${t('meals.dateLabel')}</label>
<input type="date" class="form-input" id="modal-date" value="${date}"> <input type="text" class="form-input js-date-input" id="modal-date" value="${formatDateInput(date)}" placeholder="${dateInputPlaceholder()}" inputmode="numeric">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="modal-type">${t('meals.mealTypeLabel')}</label> <label class="form-label" for="modal-type">${t('meals.mealTypeLabel')}</label>
@@ -850,13 +856,19 @@ function closeModal({ force = false } = {}) {
async function saveModal(overlay) { async function saveModal(overlay) {
const saveBtn = overlay.querySelector('#modal-save'); const saveBtn = overlay.querySelector('#modal-save');
const date = overlay.querySelector('#modal-date').value; const dateRaw = overlay.querySelector('#modal-date').value;
const date = parseDateInput(dateRaw);
const meal_type = overlay.querySelector('#modal-type').value; const meal_type = overlay.querySelector('#modal-type').value;
const title = overlay.querySelector('#modal-title').value.trim(); const title = overlay.querySelector('#modal-title').value.trim();
const notes = overlay.querySelector('#modal-notes').value.trim() || null; const notes = overlay.querySelector('#modal-notes').value.trim() || null;
const recipe_url = overlay.querySelector('#modal-recipe-url').value.trim() || null; const recipe_url = overlay.querySelector('#modal-recipe-url').value.trim() || null;
const recipe_id = overlay.querySelector('#modal-recipe-id')?.value || null; const recipe_id = overlay.querySelector('#modal-recipe-id')?.value || null;
if (!date || !isDateInputValid(dateRaw)) {
window.oikos?.showToast(t('calendar.invalidDate'), 'error');
return;
}
if (!title) { if (!title) {
window.oikos?.showToast(t('meals.titleRequired'), 'error'); window.oikos?.showToast(t('meals.titleRequired'), 'error');
return; return;
@@ -979,4 +991,3 @@ async function transferMeal(mealId) {
// -------------------------------------------------------- // --------------------------------------------------------
// Hilfsfunktion // Hilfsfunktion
// -------------------------------------------------------- // --------------------------------------------------------
+23 -7
View File
@@ -8,7 +8,7 @@ import { api } from '/api.js';
import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js'; import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js';
import { openModal as openSharedModal, closeModal, wireBlurValidation, validateAll, btnSuccess, btnError, promptModal } from '/components/modal.js'; import { openModal as openSharedModal, closeModal, wireBlurValidation, validateAll, btnSuccess, btnError, promptModal } from '/components/modal.js';
import { stagger, vibrate } from '/utils/ux.js'; import { stagger, vibrate } from '/utils/ux.js';
import { t, formatDate, formatTime } from '/i18n.js'; import { t, formatDate, formatTime, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid } from '/i18n.js';
import { esc } from '/utils/html.js'; import { esc } from '/utils/html.js';
import { refresh as refreshReminders } from '/reminders.js'; import { refresh as refreshReminders } from '/reminders.js';
@@ -346,8 +346,8 @@ function renderModalContent({ task = null, users = [], reminder = null } = {}) {
<div class="modal-grid modal-grid--2" style="margin-top:var(--space-4)"> <div class="modal-grid modal-grid--2" style="margin-top:var(--space-4)">
<div class="form-group"> <div class="form-group">
<label class="label" for="task-due-date">${t('tasks.dueDateLabel')}</label> <label class="label" for="task-due-date">${t('tasks.dueDateLabel')}</label>
<input class="input" type="date" id="task-due-date" name="due_date" <input class="input js-date-input" type="text" id="task-due-date" name="due_date"
value="${task?.due_date ?? ''}"> value="${formatDateInput(task?.due_date)}" placeholder="${dateInputPlaceholder()}" inputmode="numeric">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="label" for="task-due-time">${t('tasks.dueTimeLabel')}</label> <label class="label" for="task-due-time">${t('tasks.dueTimeLabel')}</label>
@@ -463,7 +463,7 @@ function renderReminderSection(reminder = null) {
<div id="reminder-fields" class="reminder-fields" ${hasReminder ? '' : 'style="display:none"'}> <div id="reminder-fields" class="reminder-fields" ${hasReminder ? '' : 'style="display:none"'}>
<div class="form-group" style="margin:0"> <div class="form-group" style="margin:0">
<label class="label" for="reminder-date">${t('reminders.dateLabel')}</label> <label class="label" for="reminder-date">${t('reminders.dateLabel')}</label>
<input class="input" type="date" id="reminder-date" value="${remindDate}"> <input class="input js-date-input" type="text" id="reminder-date" value="${formatDateInput(remindDate)}" placeholder="${dateInputPlaceholder()}" inputmode="numeric">
</div> </div>
<div class="form-group" style="margin:0"> <div class="form-group" style="margin:0">
<label class="label" for="reminder-time">${t('reminders.timeLabel')}</label> <label class="label" for="reminder-time">${t('reminders.timeLabel')}</label>
@@ -496,6 +496,12 @@ function openTaskModal({ task = null, users = [], reminder = null } = {}, contai
toggle?.addEventListener('change', () => { toggle?.addEventListener('change', () => {
fields.style.display = toggle.checked ? '' : 'none'; fields.style.display = toggle.checked ? '' : 'none';
}); });
panel.querySelectorAll('.js-date-input').forEach((input) => {
input.addEventListener('blur', () => {
const parsed = parseDateInput(input.value);
if (parsed) input.value = formatDateInput(parsed);
});
});
// Form-Events // Form-Events
panel.querySelector('#task-form') panel.querySelector('#task-form')
@@ -527,13 +533,25 @@ async function handleFormSubmit(e, container) {
const originalLabel = taskId ? t('common.save') : t('common.create'); const originalLabel = taskId ? t('common.save') : t('common.create');
const dueDateRaw = form.due_date?.value || '';
const dueDate = parseDateInput(dueDateRaw);
const rrule = getRRuleValues(document, 'task'); const rrule = getRRuleValues(document, 'task');
const reminderToggle = form.querySelector('#reminder-toggle');
const reminderDateRaw = form.querySelector('#reminder-date')?.value || '';
const reminderDate = parseDateInput(reminderDateRaw);
if (!isDateInputValid(dueDateRaw) || !rrule.valid_until || (reminderToggle?.checked && !isDateInputValid(reminderDateRaw))) {
errorEl.textContent = t('calendar.invalidDate');
errorEl.hidden = false;
submitBtn.disabled = false;
submitBtn.textContent = originalLabel;
return;
}
const body = { const body = {
title: form.title.value.trim(), title: form.title.value.trim(),
description: form.description.value.trim() || null, description: form.description.value.trim() || null,
priority: form.priority.value, priority: form.priority.value,
category: form.category.value, category: form.category.value,
due_date: form.due_date?.value || null, due_date: dueDate || null,
due_time: form.due_time?.value || null, due_time: form.due_time?.value || null,
assigned_to: form.assigned_to.value ? Number(form.assigned_to.value) : null, assigned_to: form.assigned_to.value ? Number(form.assigned_to.value) : null,
is_recurring: rrule.is_recurring ? 1 : 0, is_recurring: rrule.is_recurring ? 1 : 0,
@@ -554,8 +572,6 @@ async function handleFormSubmit(e, container) {
// Erinnerung speichern oder löschen // Erinnerung speichern oder löschen
if (savedTaskId) { if (savedTaskId) {
const reminderToggle = form.querySelector('#reminder-toggle');
const reminderDate = form.querySelector('#reminder-date')?.value;
const reminderTime = form.querySelector('#reminder-time')?.value || '08:00'; const reminderTime = form.querySelector('#reminder-time')?.value || '08:00';
if (reminderToggle?.checked && reminderDate) { if (reminderToggle?.checked && reminderDate) {
+14 -3
View File
@@ -4,7 +4,7 @@
* Abhängigkeiten: /i18n.js * Abhängigkeiten: /i18n.js
*/ */
import { t } from '/i18n.js'; import { t, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid } from '/i18n.js';
const FREQ_OPTIONS = () => [ const FREQ_OPTIONS = () => [
{ value: '', label: t('rrule.freqNone') }, { value: '', label: t('rrule.freqNone') },
@@ -115,7 +115,8 @@ export function renderRRuleFields(prefix, existingRule) {
<div class="form-group" style="margin-top:var(--space-3)"> <div class="form-group" style="margin-top:var(--space-3)">
<label class="label form-label" for="${prefix}-rrule-until">${t('rrule.labelUntil')}</label> <label class="label form-label" for="${prefix}-rrule-until">${t('rrule.labelUntil')}</label>
<input class="input form-input" type="date" id="${prefix}-rrule-until" value="${parsed.until}"> <input class="input form-input js-date-input" type="text" id="${prefix}-rrule-until"
value="${formatDateInput(parsed.until)}" placeholder="${dateInputPlaceholder()}" inputmode="numeric">
</div> </div>
</div> </div>
</div> </div>
@@ -153,6 +154,13 @@ export function bindRRuleEvents(root, prefix) {
intervalEl?.addEventListener('input', updateUnit); intervalEl?.addEventListener('input', updateUnit);
root.querySelectorAll('.js-date-input').forEach((input) => {
input.addEventListener('blur', () => {
const parsed = parseDateInput(input.value);
if (parsed) input.value = formatDateInput(parsed);
});
});
// Day-Toggle // Day-Toggle
root.querySelectorAll(`#${prefix}-rrule-weekdays .rrule-day`).forEach(btn => { root.querySelectorAll(`#${prefix}-rrule-weekdays .rrule-day`).forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
@@ -177,7 +185,9 @@ export function bindRRuleEvents(root, prefix) {
export function getRRuleValues(root, prefix) { export function getRRuleValues(root, prefix) {
const freq = root.querySelector(`#${prefix}-rrule-freq`)?.value || ''; const freq = root.querySelector(`#${prefix}-rrule-freq`)?.value || '';
const interval = parseInt(root.querySelector(`#${prefix}-rrule-interval`)?.value, 10) || 1; const interval = parseInt(root.querySelector(`#${prefix}-rrule-interval`)?.value, 10) || 1;
const until = root.querySelector(`#${prefix}-rrule-until`)?.value || ''; const untilInput = root.querySelector(`#${prefix}-rrule-until`);
const untilRaw = untilInput?.value || '';
const until = parseDateInput(untilRaw);
const byday = []; const byday = [];
root.querySelectorAll(`#${prefix}-rrule-weekdays .rrule-day--active`).forEach(btn => { root.querySelectorAll(`#${prefix}-rrule-weekdays .rrule-day--active`).forEach(btn => {
@@ -188,5 +198,6 @@ export function getRRuleValues(root, prefix) {
return { return {
is_recurring: !!rule, is_recurring: !!rule,
recurrence_rule: rule, recurrence_rule: rule,
valid_until: isDateInputValid(untilRaw),
}; };
} }
+51
View File
@@ -210,6 +210,9 @@
} }
.month-day__event { .month-day__event {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--text-xs); font-size: var(--text-xs);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
padding: var(--space-px) var(--space-1); padding: var(--space-px) var(--space-1);
@@ -224,6 +227,28 @@
filter: saturate(0.4); filter: saturate(0.4);
} }
.month-day__event span,
.allday-event span,
.week-event__title span,
.agenda-event__title span,
.event-popup__title span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.event-icon {
width: 16px;
height: 16px;
flex: 0 0 auto;
opacity: 0.9;
}
.event-icon--compact {
width: 12px;
height: 12px;
}
.month-day__more { .month-day__more {
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--color-text-secondary); color: var(--color-text-secondary);
@@ -381,6 +406,9 @@
} }
.week-event__title { .week-event__title {
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -506,6 +534,9 @@
} }
.agenda-event__title { .agenda-event__title {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-base); font-size: var(--text-base);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
white-space: nowrap; white-space: nowrap;
@@ -513,6 +544,14 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.modal-grid--event-title {
grid-template-columns: minmax(0, 1fr) 160px;
}
.reminder-custom {
margin-top: var(--space-3);
}
.agenda-event__meta { .agenda-event__meta {
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--color-text-secondary); color: var(--color-text-secondary);
@@ -608,6 +647,9 @@
} }
.event-popup__title { .event-popup__title {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-base); font-size: var(--text-base);
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
@@ -647,6 +689,9 @@
} }
.allday-event { .allday-event {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--text-xs); font-size: var(--text-xs);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
padding: var(--space-px) var(--space-1); padding: var(--space-px) var(--space-1);
@@ -660,6 +705,12 @@
filter: saturate(0.4); filter: saturate(0.4);
} }
@media (max-width: 640px) {
.modal-grid--event-title {
grid-template-columns: 1fr;
}
}
/* -------------------------------------------------------- /* --------------------------------------------------------
* Kalender-Name-Label (Agenda, Popup) * Kalender-Name-Label (Agenda, Popup)
* -------------------------------------------------------- */ * -------------------------------------------------------- */
+4 -4
View File
@@ -13,10 +13,10 @@
* → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit) * → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit)
*/ */
const SHELL_CACHE = 'oikos-shell-v56'; const SHELL_CACHE = 'oikos-shell-v57';
const PAGES_CACHE = 'oikos-pages-v51'; const PAGES_CACHE = 'oikos-pages-v52';
const LOCALES_CACHE = 'oikos-locales-v3'; const LOCALES_CACHE = 'oikos-locales-v4';
const ASSETS_CACHE = 'oikos-assets-v51'; const ASSETS_CACHE = 'oikos-assets-v52';
const BYPASS_CACHE = 'oikos-bypass-flag'; const BYPASS_CACHE = 'oikos-bypass-flag';
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, LOCALES_CACHE, ASSETS_CACHE]; const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, LOCALES_CACHE, ASSETS_CACHE];
+5
View File
@@ -89,6 +89,7 @@ const MIGRATIONS_SQL = {
all_day INTEGER NOT NULL DEFAULT 0, all_day INTEGER NOT NULL DEFAULT 0,
location TEXT, location TEXT,
color TEXT NOT NULL DEFAULT '#007AFF', color TEXT NOT NULL DEFAULT '#007AFF',
icon TEXT NOT NULL DEFAULT 'calendar',
assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL, assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
external_calendar_id TEXT, external_calendar_id TEXT,
@@ -266,6 +267,7 @@ const MIGRATIONS_SQL = {
all_day INTEGER NOT NULL DEFAULT 0, all_day INTEGER NOT NULL DEFAULT 0,
location TEXT, location TEXT,
color TEXT NOT NULL DEFAULT '#007AFF', color TEXT NOT NULL DEFAULT '#007AFF',
icon TEXT NOT NULL DEFAULT 'calendar',
assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL, assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
external_calendar_id TEXT, external_calendar_id TEXT,
@@ -322,6 +324,9 @@ const MIGRATIONS_SQL = {
ALTER TABLE meals ADD COLUMN recipe_id INTEGER REFERENCES recipes(id) ON DELETE SET NULL; ALTER TABLE meals ADD COLUMN recipe_id INTEGER REFERENCES recipes(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_meals_recipe_id ON meals(recipe_id); CREATE INDEX IF NOT EXISTS idx_meals_recipe_id ON meals(recipe_id);
`, `,
14: `
ALTER TABLE calendar_events ADD COLUMN icon TEXT NOT NULL DEFAULT 'calendar';
`,
}; };
export { MIGRATIONS_SQL }; export { MIGRATIONS_SQL };
+7
View File
@@ -734,6 +734,13 @@ const MIGRATIONS = [
ALTER TABLE users ADD COLUMN avatar_data TEXT; ALTER TABLE users ADD COLUMN avatar_data TEXT;
`, `,
}, },
{
version: 21,
description: 'Calendar event icons',
up: `
ALTER TABLE calendar_events ADD COLUMN icon TEXT NOT NULL DEFAULT 'calendar';
`,
},
]; ];
/** /**
+21 -4
View File
@@ -21,6 +21,12 @@ const router = express.Router();
const VALID_SOURCES = ['local', 'google', 'apple', 'ics']; const VALID_SOURCES = ['local', 'google', 'apple', 'ics'];
const ICS_COLOR_RE = /^#[0-9a-fA-F]{6}$/; const ICS_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
const VALID_EVENT_ICONS = new Set([
'calendar', 'tooth', 'stethoscope', 'heart-pulse', 'briefcase', 'plane',
'utensils', 'cake', 'car', 'graduation-cap', 'dumbbell', 'home',
'shopping-bag', 'music', 'party-popper', 'paw-print', 'scissors',
'book-open', 'users', 'bell',
]);
function getUserId(req) { function getUserId(req) {
const candidates = [req.authUserId, req.user?.id, req.session?.userId]; const candidates = [req.authUserId, req.user?.id, req.session?.userId];
@@ -35,6 +41,11 @@ function isAdminUser(req) {
return req.authRole === 'admin' || req.session?.isAdmin === true || req.session?.role === 'admin'; return req.authRole === 'admin' || req.session?.isAdmin === true || req.session?.role === 'admin';
} }
function eventIcon(value) {
const icon = typeof value === 'string' && value.trim() ? value.trim() : 'calendar';
return VALID_EVENT_ICONS.has(icon) ? icon : null;
}
// -------------------------------------------------------- // --------------------------------------------------------
// RRULE-Expansion: alle Vorkommen eines wiederkehrenden Events // RRULE-Expansion: alle Vorkommen eines wiederkehrenden Events
// innerhalb [from, to] generieren (inklusive beider Grenzen). // innerhalb [from, to] generieren (inklusive beider Grenzen).
@@ -531,7 +542,7 @@ router.get('/:id', (req, res) => {
// POST /api/v1/calendar // POST /api/v1/calendar
// Neuen Termin anlegen. // Neuen Termin anlegen.
// Body: { title, description?, start_datetime, end_datetime?, // Body: { title, description?, start_datetime, end_datetime?,
// all_day?, location?, color?, assigned_to?, // all_day?, location?, color?, icon?, assigned_to?,
// recurrence_rule? } // recurrence_rule? }
// Response: { data: Event } // Response: { data: Event }
// -------------------------------------------------------- // --------------------------------------------------------
@@ -553,10 +564,12 @@ router.post('/', (req, res) => {
const vStart = datetime(req.body.start_datetime, 'Startdatum', true); const vStart = datetime(req.body.start_datetime, 'Startdatum', true);
const vEnd = datetime(req.body.end_datetime, 'Enddatum'); const vEnd = datetime(req.body.end_datetime, 'Enddatum');
const vColor = color(req.body.color || '#007AFF', 'Farbe'); const vColor = color(req.body.color || '#007AFF', 'Farbe');
const vIcon = eventIcon(req.body.icon);
const vLoc = str(req.body.location, 'Ort', { max: MAX_TITLE, required: false }); const vLoc = str(req.body.location, 'Ort', { max: MAX_TITLE, required: false });
const vRrule = rrule(req.body.recurrence_rule, 'Wiederholung'); const vRrule = rrule(req.body.recurrence_rule, 'Wiederholung');
const errors = collectErrors([vTitle, vDesc, vStart, vEnd, vColor, vLoc, vRrule]); const errors = collectErrors([vTitle, vDesc, vStart, vEnd, vColor, vLoc, vRrule]);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
if (!vIcon) return res.status(400).json({ error: 'icon: invalid calendar event icon.', code: 400 });
const { all_day = 0, assigned_to = null } = req.body; const { all_day = 0, assigned_to = null } = req.body;
@@ -568,13 +581,13 @@ router.post('/', (req, res) => {
const result = db.get().prepare(` const result = db.get().prepare(`
INSERT INTO calendar_events INSERT INTO calendar_events
(title, description, start_datetime, end_datetime, all_day, (title, description, start_datetime, end_datetime, all_day,
location, color, assigned_to, created_by, recurrence_rule) location, color, icon, assigned_to, created_by, recurrence_rule)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
vTitle.value, vDesc.value, vTitle.value, vDesc.value,
vStart.value, vEnd.value, vStart.value, vEnd.value,
all_day ? 1 : 0, vLoc.value, all_day ? 1 : 0, vLoc.value,
vColor.value, assigned_to || null, vColor.value, vIcon, assigned_to || null,
userId, vRrule.value userId, vRrule.value
); );
@@ -618,6 +631,8 @@ router.put('/:id', (req, res) => {
if (req.body.recurrence_rule !== undefined) checks.push(rrule(req.body.recurrence_rule, 'Wiederholung')); if (req.body.recurrence_rule !== undefined) checks.push(rrule(req.body.recurrence_rule, 'Wiederholung'));
const errors = collectErrors(checks); const errors = collectErrors(checks);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
const vIcon = req.body.icon !== undefined ? eventIcon(req.body.icon) : event.icon;
if (!vIcon) return res.status(400).json({ error: 'icon: invalid calendar event icon.', code: 400 });
const { const {
title, description, start_datetime, end_datetime, title, description, start_datetime, end_datetime,
@@ -635,6 +650,7 @@ router.put('/:id', (req, res) => {
all_day = COALESCE(?, all_day), all_day = COALESCE(?, all_day),
location = ?, location = ?,
color = COALESCE(?, color), color = COALESCE(?, color),
icon = COALESCE(?, icon),
assigned_to = ?, assigned_to = ?,
recurrence_rule = ?, recurrence_rule = ?,
user_modified = ? user_modified = ?
@@ -647,6 +663,7 @@ router.put('/:id', (req, res) => {
all_day !== undefined ? (all_day ? 1 : 0) : null, all_day !== undefined ? (all_day ? 1 : 0) : null,
location !== undefined ? (location || null) : event.location, location !== undefined ? (location || null) : event.location,
colorVal ?? null, colorVal ?? null,
req.body.icon !== undefined ? vIcon : null,
assigned_to !== undefined ? (assigned_to || null) : event.assigned_to, assigned_to !== undefined ? (assigned_to || null) : event.assigned_to,
recurrence_rule !== undefined ? (recurrence_rule || null) : event.recurrence_rule, recurrence_rule !== undefined ? (recurrence_rule || null) : event.recurrence_rule,
userModified, userModified,
+6 -3
View File
@@ -65,6 +65,7 @@ function syncBirthdayCalendarEvent(database, birthday) {
all_day: 1, all_day: 1,
location: null, location: null,
color: BIRTHDAY_COLOR, color: BIRTHDAY_COLOR,
icon: 'cake',
assigned_to: null, assigned_to: null,
recurrence_rule: BIRTHDAY_RRULE, recurrence_rule: BIRTHDAY_RRULE,
created_by: birthday.created_by, created_by: birthday.created_by,
@@ -76,7 +77,7 @@ function syncBirthdayCalendarEvent(database, birthday) {
database.prepare(` database.prepare(`
UPDATE calendar_events UPDATE calendar_events
SET title = ?, description = ?, start_datetime = ?, end_datetime = ?, all_day = ?, SET title = ?, description = ?, start_datetime = ?, end_datetime = ?, all_day = ?,
location = ?, color = ?, assigned_to = ?, recurrence_rule = ?, created_by = ?, location = ?, color = ?, icon = ?, assigned_to = ?, recurrence_rule = ?, created_by = ?,
external_source = 'local' external_source = 'local'
WHERE id = ? WHERE id = ?
`).run( `).run(
@@ -87,6 +88,7 @@ function syncBirthdayCalendarEvent(database, birthday) {
payload.all_day, payload.all_day,
payload.location, payload.location,
payload.color, payload.color,
payload.icon,
payload.assigned_to, payload.assigned_to,
payload.recurrence_rule, payload.recurrence_rule,
payload.created_by, payload.created_by,
@@ -99,8 +101,8 @@ function syncBirthdayCalendarEvent(database, birthday) {
const result = database.prepare(` const result = database.prepare(`
INSERT INTO calendar_events INSERT INTO calendar_events
(title, description, start_datetime, end_datetime, all_day, location, color, (title, description, start_datetime, end_datetime, all_day, location, color,
assigned_to, created_by, recurrence_rule, external_source) icon, assigned_to, created_by, recurrence_rule, external_source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'local') VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'local')
`).run( `).run(
payload.title, payload.title,
payload.description, payload.description,
@@ -109,6 +111,7 @@ function syncBirthdayCalendarEvent(database, birthday) {
payload.all_day, payload.all_day,
payload.location, payload.location,
payload.color, payload.color,
payload.icon,
payload.assigned_to, payload.assigned_to,
payload.created_by, payload.created_by,
payload.recurrence_rule, payload.recurrence_rule,
+5
View File
@@ -92,6 +92,11 @@ test('Termin abrufen (mit assigned_name via JOIN)', () => {
assert(ev.assigned_color === '#34C759'); assert(ev.assigned_color === '#34C759');
}); });
test('Termin-Icon hat Default-Wert', () => {
const ev = db.prepare('SELECT icon FROM calendar_events WHERE id = ?').get(ev1);
assert(ev.icon === 'calendar', `icon: ${ev.icon}`);
});
test('Termin aktualisieren (Titel + Farbe)', () => { test('Termin aktualisieren (Titel + Farbe)', () => {
db.prepare(`UPDATE calendar_events SET title = 'Zahnarzt Dr. Müller', color = '#007AFF' WHERE id = ?`).run(ev1); db.prepare(`UPDATE calendar_events SET title = 'Zahnarzt Dr. Müller', color = '#007AFF' WHERE id = ?`).run(ev1);
const ev = db.prepare('SELECT title, color FROM calendar_events WHERE id = ?').get(ev1); const ev = db.prepare('SELECT title, color FROM calendar_events WHERE id = ?').get(ev1);