Add calendar event icons and flexible date inputs
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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];
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user