Merge pull request #95 from rafaelfoster/main
Improves the calendar event customization experience with persisted event icons and a richer reminder/date handling flow
This commit is contained in:
@@ -89,6 +89,10 @@ function getDateFormatPreference() {
|
||||
return ['mdy', 'dmy', 'ymd'].includes(stored) ? stored : DEFAULT_DATE_FORMAT;
|
||||
}
|
||||
|
||||
export function getDateFormat() {
|
||||
return getDateFormatPreference();
|
||||
}
|
||||
|
||||
function formatDateParts(date, useUtc = false) {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
@@ -121,6 +125,52 @@ export function formatDate(date) {
|
||||
return formatDateParts(date);
|
||||
}
|
||||
|
||||
export function dateInputPlaceholder() {
|
||||
switch (getDateFormatPreference()) {
|
||||
case 'dmy': return 'DD/MM/YYYY';
|
||||
case 'ymd': return 'YYYY-MM-DD';
|
||||
default: return 'MM/DD/YYYY';
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDateInput(date) {
|
||||
if (!date) return '';
|
||||
return formatDate(date);
|
||||
}
|
||||
|
||||
export function parseDateInput(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return '';
|
||||
|
||||
const isoMatch = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (isoMatch) return isValidDateParts(isoMatch[1], isoMatch[2], isoMatch[3]) ? raw : '';
|
||||
|
||||
const slashMatch = raw.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
||||
if (!slashMatch) return '';
|
||||
|
||||
const [, first, second, year] = slashMatch;
|
||||
const [month, day] = getDateFormatPreference() === 'dmy'
|
||||
? [second, first]
|
||||
: [first, second];
|
||||
|
||||
if (!isValidDateParts(year, month, day)) return '';
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function isDateInputValid(value) {
|
||||
const raw = String(value || '').trim();
|
||||
return !raw || !!parseDateInput(raw);
|
||||
}
|
||||
|
||||
function isValidDateParts(year, month, day) {
|
||||
const y = Number(year);
|
||||
const m = Number(month);
|
||||
const d = Number(day);
|
||||
if (!Number.isInteger(y) || !Number.isInteger(m) || !Number.isInteger(d)) return false;
|
||||
const date = new Date(Date.UTC(y, m - 1, d));
|
||||
return date.getUTCFullYear() === y && date.getUTCMonth() === m - 1 && date.getUTCDate() === d;
|
||||
}
|
||||
|
||||
/** Uhrzeit locale-aware formatieren */
|
||||
export function formatTime(date) {
|
||||
if (date == null) return '';
|
||||
|
||||
+14
-2
@@ -349,7 +349,9 @@
|
||||
"ics": {
|
||||
"reset": "إعادة التعيين للأصل",
|
||||
"resetToast": "تم إعادة تعيين التغييرات."
|
||||
}
|
||||
},
|
||||
"iconLabel": "Icon",
|
||||
"invalidDate": "Use a valid date in the selected date format."
|
||||
},
|
||||
"notes": {
|
||||
"title": "لوحة الملاحظات",
|
||||
@@ -856,7 +858,17 @@
|
||||
"notificationEnable": "تفعيل الإشعارات",
|
||||
"notificationEnabled": "الإشعارات نشطة",
|
||||
"notificationDenied": "الإشعارات محظورة",
|
||||
"notificationHint": "احصل على إشعارات حتى عندما يكون التطبيق مفتوحًا."
|
||||
"notificationHint": "احصل على إشعارات حتى عندما يكون التطبيق مفتوحًا.",
|
||||
"offset2days": "2 days before",
|
||||
"offset1week": "1 week before",
|
||||
"offset2weeks": "2 weeks before",
|
||||
"offsetCustom": "Custom...",
|
||||
"customAmountLabel": "Number",
|
||||
"customUnitLabel": "Unit",
|
||||
"customMinutes": "Minutes",
|
||||
"customHours": "Hours",
|
||||
"customDays": "Days",
|
||||
"customWeeks": "Weeks"
|
||||
},
|
||||
"onboarding": {
|
||||
"step1Title": "Welcome to Oikos",
|
||||
|
||||
+25
-13
@@ -366,7 +366,9 @@
|
||||
"ics": {
|
||||
"reset": "Auf Original zurücksetzen",
|
||||
"resetToast": "Änderungen zurückgesetzt."
|
||||
}
|
||||
},
|
||||
"iconLabel": "Icon",
|
||||
"invalidDate": "Bitte ein gültiges Datum im ausgewählten Format verwenden."
|
||||
},
|
||||
"notes": {
|
||||
"title": "Notizen",
|
||||
@@ -823,7 +825,17 @@
|
||||
"notificationDenied": "Benachrichtigungen blockiert",
|
||||
"notificationHint": "Erhalte Benachrichtigungen auch wenn die App geöffnet ist.",
|
||||
"pendingBadgeTitle": "{{count}} fällige Erinnerung",
|
||||
"pendingBadgeTitlePlural": "{{count}} fällige Erinnerungen"
|
||||
"pendingBadgeTitlePlural": "{{count}} fällige Erinnerungen",
|
||||
"offset2days": "2 Tage vorher",
|
||||
"offset1week": "1 Woche vorher",
|
||||
"offset2weeks": "2 Wochen vorher",
|
||||
"offsetCustom": "Benutzerdefiniert...",
|
||||
"customAmountLabel": "Anzahl",
|
||||
"customUnitLabel": "Einheit",
|
||||
"customMinutes": "Minuten",
|
||||
"customHours": "Stunden",
|
||||
"customDays": "Tage",
|
||||
"customWeeks": "Wochen"
|
||||
},
|
||||
"birthdays": {
|
||||
"title": "Geburtstage",
|
||||
@@ -893,24 +905,24 @@
|
||||
"banner": "Offline – Verbindung wird wiederhergestellt…"
|
||||
},
|
||||
"emptyHint": {
|
||||
"tasks": "Tippe auf + um deine erste Aufgabe zu erstellen. Wische eine Karte nach links zum Löschen.",
|
||||
"tasks": "Tippe auf + um deine erste Aufgabe zu erstellen. Wische eine Karte nach links zum Löschen.",
|
||||
"calendar": "Verbinde Google Kalender unter Einstellungen → Integrationen für automatische Synchronisation.",
|
||||
"shopping": "Füge Artikel hinzu und wische zum Abhaken oder Löschen.",
|
||||
"notes": "Tippe auf + für eine neue Notiz. Notizen werden im Volltext durchsucht.",
|
||||
"notes": "Tippe auf + für eine neue Notiz. Notizen werden im Volltext durchsucht.",
|
||||
"contacts": "Lege wichtige Kontakte an — Arzt, Schule, Notfall — für Schnellzugriff.",
|
||||
"budget": "Erstelle Kategorien und trage Einnahmen und Ausgaben ein.",
|
||||
"meals": "Plane Mahlzeiten für die Woche und verknüpfe Rezepte.",
|
||||
"budget": "Erstelle Kategorien und trage Einnahmen und Ausgaben ein.",
|
||||
"meals": "Plane Mahlzeiten für die Woche und verknüpfe Rezepte.",
|
||||
"birthdays": "Trage Geburtstage ein — du erhältst eine Erinnerung rechtzeitig.",
|
||||
"recipes": "Lege Rezepte an und verknüpfe sie mit deiner Mahlzeitenplanung."
|
||||
"recipes": "Lege Rezepte an und verknüpfe sie mit deiner Mahlzeitenplanung."
|
||||
},
|
||||
"shortcuts": {
|
||||
"search": "Suche öffnen",
|
||||
"new": "Neuen Eintrag erstellen",
|
||||
"help": "Tastenkombinationen",
|
||||
"goDash": "Dashboard",
|
||||
"search": "Suche öffnen",
|
||||
"new": "Neuen Eintrag erstellen",
|
||||
"help": "Tastenkombinationen",
|
||||
"goDash": "Dashboard",
|
||||
"goTasks": "Aufgaben",
|
||||
"goCal": "Kalender",
|
||||
"goShop": "Einkaufsliste",
|
||||
"goCal": "Kalender",
|
||||
"goShop": "Einkaufsliste",
|
||||
"goNotes": "Notizen"
|
||||
}
|
||||
}
|
||||
|
||||
+14
-2
@@ -349,7 +349,9 @@
|
||||
"ics": {
|
||||
"reset": "Επαναφορά στο αρχικό",
|
||||
"resetToast": "Οι αλλαγές επαναφέρθηκαν."
|
||||
}
|
||||
},
|
||||
"iconLabel": "Icon",
|
||||
"invalidDate": "Use a valid date in the selected date format."
|
||||
},
|
||||
"notes": {
|
||||
"title": "Σημειώσεις",
|
||||
@@ -856,7 +858,17 @@
|
||||
"notificationEnable": "Ενεργοποίηση ειδοποιήσεων",
|
||||
"notificationEnabled": "Ειδοποιήσεις ενεργές",
|
||||
"notificationDenied": "Ειδοποιήσεις αποκλεισμένες",
|
||||
"notificationHint": "Λάβετε ειδοποιήσεις ακόμα και όταν η εφαρμογή είναι ανοιχτή."
|
||||
"notificationHint": "Λάβετε ειδοποιήσεις ακόμα και όταν η εφαρμογή είναι ανοιχτή.",
|
||||
"offset2days": "2 days before",
|
||||
"offset1week": "1 week before",
|
||||
"offset2weeks": "2 weeks before",
|
||||
"offsetCustom": "Custom...",
|
||||
"customAmountLabel": "Number",
|
||||
"customUnitLabel": "Unit",
|
||||
"customMinutes": "Minutes",
|
||||
"customHours": "Hours",
|
||||
"customDays": "Days",
|
||||
"customWeeks": "Weeks"
|
||||
},
|
||||
"onboarding": {
|
||||
"step1Title": "Welcome to Oikos",
|
||||
|
||||
+14
-2
@@ -349,7 +349,9 @@
|
||||
"ics": {
|
||||
"reset": "Reset to original",
|
||||
"resetToast": "Changes reset."
|
||||
}
|
||||
},
|
||||
"iconLabel": "Icon",
|
||||
"invalidDate": "Use a valid date in the selected date format."
|
||||
},
|
||||
"notes": {
|
||||
"title": "Board",
|
||||
@@ -798,7 +800,17 @@
|
||||
"notificationDenied": "Notifications blocked",
|
||||
"notificationHint": "Receive notifications while the app is open.",
|
||||
"pendingBadgeTitle": "{{count}} reminder due",
|
||||
"pendingBadgeTitlePlural": "{{count}} reminders due"
|
||||
"pendingBadgeTitlePlural": "{{count}} reminders due",
|
||||
"offset2days": "2 days before",
|
||||
"offset1week": "1 week before",
|
||||
"offset2weeks": "2 weeks before",
|
||||
"offsetCustom": "Custom...",
|
||||
"customAmountLabel": "Number",
|
||||
"customUnitLabel": "Unit",
|
||||
"customMinutes": "Minutes",
|
||||
"customHours": "Hours",
|
||||
"customDays": "Days",
|
||||
"customWeeks": "Weeks"
|
||||
},
|
||||
"birthdays": {
|
||||
"title": "Birthdays",
|
||||
|
||||
+14
-2
@@ -349,7 +349,9 @@
|
||||
"ics": {
|
||||
"reset": "Restaurar original",
|
||||
"resetToast": "Cambios restablecidos."
|
||||
}
|
||||
},
|
||||
"iconLabel": "Icon",
|
||||
"invalidDate": "Use a valid date in the selected date format."
|
||||
},
|
||||
"notes": {
|
||||
"title": "Notas",
|
||||
@@ -856,7 +858,17 @@
|
||||
"notificationEnable": "Activar notificaciones",
|
||||
"notificationEnabled": "Notificaciones activas",
|
||||
"notificationDenied": "Notificaciones bloqueadas",
|
||||
"notificationHint": "Recibe notificaciones incluso cuando la aplicación está abierta."
|
||||
"notificationHint": "Recibe notificaciones incluso cuando la aplicación está abierta.",
|
||||
"offset2days": "2 days before",
|
||||
"offset1week": "1 week before",
|
||||
"offset2weeks": "2 weeks before",
|
||||
"offsetCustom": "Custom...",
|
||||
"customAmountLabel": "Number",
|
||||
"customUnitLabel": "Unit",
|
||||
"customMinutes": "Minutes",
|
||||
"customHours": "Hours",
|
||||
"customDays": "Days",
|
||||
"customWeeks": "Weeks"
|
||||
},
|
||||
"onboarding": {
|
||||
"step1Title": "Welcome to Oikos",
|
||||
|
||||
+14
-2
@@ -349,7 +349,9 @@
|
||||
"ics": {
|
||||
"reset": "Réinitialiser",
|
||||
"resetToast": "Modifications annulées."
|
||||
}
|
||||
},
|
||||
"iconLabel": "Icon",
|
||||
"invalidDate": "Use a valid date in the selected date format."
|
||||
},
|
||||
"notes": {
|
||||
"title": "Notes",
|
||||
@@ -856,7 +858,17 @@
|
||||
"notificationEnable": "Activer les notifications",
|
||||
"notificationEnabled": "Notifications actives",
|
||||
"notificationDenied": "Notifications bloquées",
|
||||
"notificationHint": "Recevez des notifications même lorsque l'application est ouverte."
|
||||
"notificationHint": "Recevez des notifications même lorsque l'application est ouverte.",
|
||||
"offset2days": "2 days before",
|
||||
"offset1week": "1 week before",
|
||||
"offset2weeks": "2 weeks before",
|
||||
"offsetCustom": "Custom...",
|
||||
"customAmountLabel": "Number",
|
||||
"customUnitLabel": "Unit",
|
||||
"customMinutes": "Minutes",
|
||||
"customHours": "Hours",
|
||||
"customDays": "Days",
|
||||
"customWeeks": "Weeks"
|
||||
},
|
||||
"onboarding": {
|
||||
"step1Title": "Welcome to Oikos",
|
||||
|
||||
+14
-2
@@ -349,7 +349,9 @@
|
||||
"ics": {
|
||||
"reset": "मूल पर वापस जाएं",
|
||||
"resetToast": "परिवर्तन रीसेट हो गए।"
|
||||
}
|
||||
},
|
||||
"iconLabel": "Icon",
|
||||
"invalidDate": "Use a valid date in the selected date format."
|
||||
},
|
||||
"notes": {
|
||||
"title": "नोट बोर्ड",
|
||||
@@ -856,7 +858,17 @@
|
||||
"notificationEnable": "सूचनाएं सक्षम करें",
|
||||
"notificationEnabled": "सूचनाएं सक्रिय",
|
||||
"notificationDenied": "सूचनाएं अवरुद्ध",
|
||||
"notificationHint": "ऐप खुली होने पर भी सूचनाएं प्राप्त करें।"
|
||||
"notificationHint": "ऐप खुली होने पर भी सूचनाएं प्राप्त करें।",
|
||||
"offset2days": "2 days before",
|
||||
"offset1week": "1 week before",
|
||||
"offset2weeks": "2 weeks before",
|
||||
"offsetCustom": "Custom...",
|
||||
"customAmountLabel": "Number",
|
||||
"customUnitLabel": "Unit",
|
||||
"customMinutes": "Minutes",
|
||||
"customHours": "Hours",
|
||||
"customDays": "Days",
|
||||
"customWeeks": "Weeks"
|
||||
},
|
||||
"onboarding": {
|
||||
"step1Title": "Welcome to Oikos",
|
||||
|
||||
+14
-2
@@ -349,7 +349,9 @@
|
||||
"ics": {
|
||||
"reset": "Ripristina originale",
|
||||
"resetToast": "Modifiche ripristinate."
|
||||
}
|
||||
},
|
||||
"iconLabel": "Icon",
|
||||
"invalidDate": "Use a valid date in the selected date format."
|
||||
},
|
||||
"notes": {
|
||||
"title": "Bacheca",
|
||||
@@ -856,7 +858,17 @@
|
||||
"notificationEnable": "Attiva notifiche",
|
||||
"notificationEnabled": "Notifiche attive",
|
||||
"notificationDenied": "Notifiche bloccate",
|
||||
"notificationHint": "Ricevi notifiche anche quando l'app è aperta."
|
||||
"notificationHint": "Ricevi notifiche anche quando l'app è aperta.",
|
||||
"offset2days": "2 days before",
|
||||
"offset1week": "1 week before",
|
||||
"offset2weeks": "2 weeks before",
|
||||
"offsetCustom": "Custom...",
|
||||
"customAmountLabel": "Number",
|
||||
"customUnitLabel": "Unit",
|
||||
"customMinutes": "Minutes",
|
||||
"customHours": "Hours",
|
||||
"customDays": "Days",
|
||||
"customWeeks": "Weeks"
|
||||
},
|
||||
"onboarding": {
|
||||
"step1Title": "Welcome to Oikos",
|
||||
|
||||
+14
-2
@@ -349,7 +349,9 @@
|
||||
"ics": {
|
||||
"reset": "元に戻す",
|
||||
"resetToast": "変更がリセットされました。"
|
||||
}
|
||||
},
|
||||
"iconLabel": "Icon",
|
||||
"invalidDate": "Use a valid date in the selected date format."
|
||||
},
|
||||
"notes": {
|
||||
"title": "メモボード",
|
||||
@@ -856,7 +858,17 @@
|
||||
"notificationEnable": "通知を有効にする",
|
||||
"notificationEnabled": "通知が有効",
|
||||
"notificationDenied": "通知がブロックされています",
|
||||
"notificationHint": "アプリが開いているときでも通知を受け取ります。"
|
||||
"notificationHint": "アプリが開いているときでも通知を受け取ります。",
|
||||
"offset2days": "2 days before",
|
||||
"offset1week": "1 week before",
|
||||
"offset2weeks": "2 weeks before",
|
||||
"offsetCustom": "Custom...",
|
||||
"customAmountLabel": "Number",
|
||||
"customUnitLabel": "Unit",
|
||||
"customMinutes": "Minutes",
|
||||
"customHours": "Hours",
|
||||
"customDays": "Days",
|
||||
"customWeeks": "Weeks"
|
||||
},
|
||||
"onboarding": {
|
||||
"step1Title": "Welcome to Oikos",
|
||||
|
||||
+14
-2
@@ -349,7 +349,9 @@
|
||||
"ics": {
|
||||
"reset": "Restaurar original",
|
||||
"resetToast": "Alterações restauradas."
|
||||
}
|
||||
},
|
||||
"iconLabel": "Ícone",
|
||||
"invalidDate": "Use uma data válida no formato selecionado."
|
||||
},
|
||||
"notes": {
|
||||
"title": "Quadro de notas",
|
||||
@@ -857,7 +859,17 @@
|
||||
"notificationEnable": "Ativar notificações",
|
||||
"notificationEnabled": "Notificações ativas",
|
||||
"notificationDenied": "Notificações bloqueadas",
|
||||
"notificationHint": "Receba notificações mesmo quando a aplicação está aberta."
|
||||
"notificationHint": "Receba notificações mesmo quando a aplicação está aberta.",
|
||||
"offset2days": "2 dias antes",
|
||||
"offset1week": "1 semana antes",
|
||||
"offset2weeks": "2 semanas antes",
|
||||
"offsetCustom": "Personalizado...",
|
||||
"customAmountLabel": "Número",
|
||||
"customUnitLabel": "Unidade",
|
||||
"customMinutes": "Minutos",
|
||||
"customHours": "Horas",
|
||||
"customDays": "Dias",
|
||||
"customWeeks": "Semanas"
|
||||
},
|
||||
"onboarding": {
|
||||
"step1Title": "Welcome to Oikos",
|
||||
|
||||
+14
-2
@@ -349,7 +349,9 @@
|
||||
"ics": {
|
||||
"reset": "Сбросить к исходному",
|
||||
"resetToast": "Изменения сброшены."
|
||||
}
|
||||
},
|
||||
"iconLabel": "Icon",
|
||||
"invalidDate": "Use a valid date in the selected date format."
|
||||
},
|
||||
"notes": {
|
||||
"title": "Заметки",
|
||||
@@ -856,7 +858,17 @@
|
||||
"notificationEnable": "Включить уведомления",
|
||||
"notificationEnabled": "Уведомления активны",
|
||||
"notificationDenied": "Уведомления заблокированы",
|
||||
"notificationHint": "Получайте уведомления, даже когда приложение открыто."
|
||||
"notificationHint": "Получайте уведомления, даже когда приложение открыто.",
|
||||
"offset2days": "2 days before",
|
||||
"offset1week": "1 week before",
|
||||
"offset2weeks": "2 weeks before",
|
||||
"offsetCustom": "Custom...",
|
||||
"customAmountLabel": "Number",
|
||||
"customUnitLabel": "Unit",
|
||||
"customMinutes": "Minutes",
|
||||
"customHours": "Hours",
|
||||
"customDays": "Days",
|
||||
"customWeeks": "Weeks"
|
||||
},
|
||||
"onboarding": {
|
||||
"step1Title": "Welcome to Oikos",
|
||||
|
||||
+14
-2
@@ -349,7 +349,9 @@
|
||||
"ics": {
|
||||
"reset": "Återställ till original",
|
||||
"resetToast": "Ändringar återställda."
|
||||
}
|
||||
},
|
||||
"iconLabel": "Icon",
|
||||
"invalidDate": "Use a valid date in the selected date format."
|
||||
},
|
||||
"notes": {
|
||||
"title": "Anteckningar",
|
||||
@@ -856,7 +858,17 @@
|
||||
"notificationEnable": "Aktivera notiser",
|
||||
"notificationEnabled": "Notiser aktiva",
|
||||
"notificationDenied": "Notiser blockerade",
|
||||
"notificationHint": "Få notiser även när appen är öppen."
|
||||
"notificationHint": "Få notiser även när appen är öppen.",
|
||||
"offset2days": "2 days before",
|
||||
"offset1week": "1 week before",
|
||||
"offset2weeks": "2 weeks before",
|
||||
"offsetCustom": "Custom...",
|
||||
"customAmountLabel": "Number",
|
||||
"customUnitLabel": "Unit",
|
||||
"customMinutes": "Minutes",
|
||||
"customHours": "Hours",
|
||||
"customDays": "Days",
|
||||
"customWeeks": "Weeks"
|
||||
},
|
||||
"onboarding": {
|
||||
"step1Title": "Welcome to Oikos",
|
||||
|
||||
+14
-2
@@ -349,7 +349,9 @@
|
||||
"ics": {
|
||||
"reset": "Orijinale sıfırla",
|
||||
"resetToast": "Değişiklikler sıfırlandı."
|
||||
}
|
||||
},
|
||||
"iconLabel": "Icon",
|
||||
"invalidDate": "Use a valid date in the selected date format."
|
||||
},
|
||||
"notes": {
|
||||
"title": "Notlar",
|
||||
@@ -856,7 +858,17 @@
|
||||
"notificationEnable": "Bildirimleri etkinleştir",
|
||||
"notificationEnabled": "Bildirimler etkin",
|
||||
"notificationDenied": "Bildirimler engellendi",
|
||||
"notificationHint": "Uygulama açıkken bile bildirim alın."
|
||||
"notificationHint": "Uygulama açıkken bile bildirim alın.",
|
||||
"offset2days": "2 days before",
|
||||
"offset1week": "1 week before",
|
||||
"offset2weeks": "2 weeks before",
|
||||
"offsetCustom": "Custom...",
|
||||
"customAmountLabel": "Number",
|
||||
"customUnitLabel": "Unit",
|
||||
"customMinutes": "Minutes",
|
||||
"customHours": "Hours",
|
||||
"customDays": "Days",
|
||||
"customWeeks": "Weeks"
|
||||
},
|
||||
"onboarding": {
|
||||
"step1Title": "Welcome to Oikos",
|
||||
|
||||
+14
-2
@@ -349,7 +349,9 @@
|
||||
"ics": {
|
||||
"reset": "Скинути до оригіналу",
|
||||
"resetToast": "Зміни скинуто."
|
||||
}
|
||||
},
|
||||
"iconLabel": "Icon",
|
||||
"invalidDate": "Use a valid date in the selected date format."
|
||||
},
|
||||
"notes": {
|
||||
"title": "Нотатки",
|
||||
@@ -798,7 +800,17 @@
|
||||
"notificationDenied": "Сповіщення заблоковано",
|
||||
"notificationHint": "Отримуйте сповіщення, поки додаток відкрито.",
|
||||
"pendingBadgeTitle": "{{count}} нагадування",
|
||||
"pendingBadgeTitlePlural": "{{count}} нагадувань"
|
||||
"pendingBadgeTitlePlural": "{{count}} нагадувань",
|
||||
"offset2days": "2 days before",
|
||||
"offset1week": "1 week before",
|
||||
"offset2weeks": "2 weeks before",
|
||||
"offsetCustom": "Custom...",
|
||||
"customAmountLabel": "Number",
|
||||
"customUnitLabel": "Unit",
|
||||
"customMinutes": "Minutes",
|
||||
"customHours": "Hours",
|
||||
"customDays": "Days",
|
||||
"customWeeks": "Weeks"
|
||||
},
|
||||
"recipes": {
|
||||
"title": "Recipes",
|
||||
|
||||
+14
-2
@@ -349,7 +349,9 @@
|
||||
"ics": {
|
||||
"reset": "重置为原始",
|
||||
"resetToast": "更改已重置。"
|
||||
}
|
||||
},
|
||||
"iconLabel": "Icon",
|
||||
"invalidDate": "Use a valid date in the selected date format."
|
||||
},
|
||||
"notes": {
|
||||
"title": "便签板",
|
||||
@@ -856,7 +858,17 @@
|
||||
"notificationEnable": "启用通知",
|
||||
"notificationEnabled": "通知已启用",
|
||||
"notificationDenied": "通知已被阻止",
|
||||
"notificationHint": "即使应用程序打开时也能收到通知。"
|
||||
"notificationHint": "即使应用程序打开时也能收到通知。",
|
||||
"offset2days": "2 days before",
|
||||
"offset1week": "1 week before",
|
||||
"offset2weeks": "2 weeks before",
|
||||
"offsetCustom": "Custom...",
|
||||
"customAmountLabel": "Number",
|
||||
"customUnitLabel": "Unit",
|
||||
"customMinutes": "Minutes",
|
||||
"customHours": "Hours",
|
||||
"customDays": "Days",
|
||||
"customWeeks": "Weeks"
|
||||
},
|
||||
"onboarding": {
|
||||
"step1Title": "Welcome to Oikos",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { api } from '/api.js';
|
||||
import { openModal as openSharedModal, closeModal, confirmModal } from '/components/modal.js';
|
||||
import { stagger, deleteWithUndo } from '/utils/ux.js';
|
||||
import { t, formatDate } from '/i18n.js';
|
||||
import { t, formatDate, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid } from '/i18n.js';
|
||||
import { esc } from '/utils/html.js';
|
||||
|
||||
let state = {
|
||||
@@ -282,7 +282,7 @@ function openBirthdayModal({ mode, birthday = null }) {
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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 class="form-group">
|
||||
<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));
|
||||
};
|
||||
nameInput.addEventListener('input', renderPreview);
|
||||
panel.querySelectorAll('.js-date-input').forEach((input) => {
|
||||
input.addEventListener('blur', () => {
|
||||
const parsed = parseDateInput(input.value);
|
||||
if (parsed) input.value = formatDateInput(parsed);
|
||||
});
|
||||
});
|
||||
panel.querySelector('#bd-photo').addEventListener('change', async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -337,14 +343,16 @@ function openBirthdayModal({ mode, birthday = null }) {
|
||||
});
|
||||
panel.querySelector('#bd-save').addEventListener('click', async () => {
|
||||
const saveBtn = panel.querySelector('#bd-save');
|
||||
const birthDateRaw = panel.querySelector('#bd-birth-date').value;
|
||||
const birthDate = parseDateInput(birthDateRaw);
|
||||
const body = {
|
||||
name: panel.querySelector('#bd-name').value.trim(),
|
||||
birth_date: panel.querySelector('#bd-birth-date').value,
|
||||
birth_date: birthDate,
|
||||
notes: panel.querySelector('#bd-notes').value.trim(),
|
||||
photo_data: photoData,
|
||||
};
|
||||
|
||||
if (!body.name || !body.birth_date) {
|
||||
if (!body.name || !body.birth_date || !isDateInputValid(birthDateRaw)) {
|
||||
window.oikos?.showToast(t('birthdays.requiredFields'), 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
+12
-5
@@ -8,7 +8,7 @@
|
||||
import { api } from '/api.js';
|
||||
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
|
||||
import { stagger, vibrate } from '/utils/ux.js';
|
||||
import { t, formatDate, getLocale } from '/i18n.js';
|
||||
import { t, formatDate, getLocale, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid } from '/i18n.js';
|
||||
import { esc } from '/utils/html.js';
|
||||
|
||||
// --------------------------------------------------------
|
||||
@@ -494,8 +494,8 @@ function openBudgetModal({ mode, entry = null }) {
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="bm-date">${t('budget.dateLabel')}</label>
|
||||
<input type="date" class="form-input" id="bm-date"
|
||||
value="${isEdit ? entry.date : today}">
|
||||
<input type="text" class="form-input js-date-input" id="bm-date"
|
||||
value="${formatDateInput(isEdit ? entry.date : today)}" placeholder="${dateInputPlaceholder()}" inputmode="numeric">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -604,6 +604,12 @@ function openBudgetModal({ mode, entry = null }) {
|
||||
panel.querySelector('#bm-category').addEventListener('change', () => updateSubcategoryOptions());
|
||||
panel.querySelector('#bm-add-category').addEventListener('click', addCategory);
|
||||
panel.querySelector('#bm-add-subcategory').addEventListener('click', addSubcategory);
|
||||
panel.querySelectorAll('.js-date-input').forEach((input) => {
|
||||
input.addEventListener('blur', () => {
|
||||
const parsed = parseDateInput(input.value);
|
||||
if (parsed) input.value = formatDateInput(parsed);
|
||||
});
|
||||
});
|
||||
|
||||
panel.querySelector('#bm-cancel').addEventListener('click', closeModal);
|
||||
|
||||
@@ -618,12 +624,13 @@ function openBudgetModal({ mode, entry = null }) {
|
||||
const absVal = parseFloat(panel.querySelector('#bm-amount').value);
|
||||
const category = panel.querySelector('#bm-category').value;
|
||||
const subcategory = currentType === 'expense' ? panel.querySelector('#bm-subcategory').value : '';
|
||||
const date = panel.querySelector('#bm-date').value;
|
||||
const dateRaw = panel.querySelector('#bm-date').value;
|
||||
const date = parseDateInput(dateRaw);
|
||||
const recurring = panel.querySelector('#bm-recurring').checked ? 1 : 0;
|
||||
|
||||
if (!title) { window.oikos?.showToast(t('common.titleRequired'), 'error'); return; }
|
||||
if (isNaN(absVal) || absVal <= 0) { window.oikos?.showToast(t('budget.validAmountRequired'), 'error'); return; }
|
||||
if (!date) { window.oikos?.showToast(t('budget.dateRequired'), 'error'); return; }
|
||||
if (!date || !isDateInputValid(dateRaw)) { window.oikos?.showToast(t('calendar.invalidDate'), 'error'); return; }
|
||||
|
||||
const amount = currentType === 'expense' ? -absVal : absVal;
|
||||
|
||||
|
||||
+314
-35
@@ -8,7 +8,7 @@ import { api } from '/api.js';
|
||||
import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js';
|
||||
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
|
||||
import { stagger } from '/utils/ux.js';
|
||||
import { t, formatTime } from '/i18n.js';
|
||||
import { t, formatDate as formatPreferredDate, formatTime, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid } from '/i18n.js';
|
||||
import { esc, fmtLocation } from '/utils/html.js';
|
||||
import { refresh as refreshReminders } from '/reminders.js';
|
||||
|
||||
@@ -59,6 +59,115 @@ const EVENT_COLOR_NAMES = () => ({
|
||||
'#30B0C7': t('calendar.colorCyan'),
|
||||
});
|
||||
|
||||
const EVENT_ICON_ALIASES = {
|
||||
tooth: 'drill',
|
||||
};
|
||||
|
||||
const EVENT_ICONS = [
|
||||
{ value: 'calendar', label: 'Calendar' },
|
||||
{ value: 'drill', label: 'Dentist' },
|
||||
{ value: 'alarm-clock', label: 'Alarm' },
|
||||
{ value: 'clock', label: 'Time' },
|
||||
{ value: 'bell', label: 'Reminder' },
|
||||
{ value: 'map-pin', label: 'Location' },
|
||||
{ value: 'home', label: 'Home' },
|
||||
{ value: 'house', label: 'House' },
|
||||
{ value: 'building', label: 'Building' },
|
||||
{ value: 'hospital', label: 'Hospital' },
|
||||
{ value: 'stethoscope', label: 'Doctor' },
|
||||
{ value: 'syringe', label: 'Vaccine' },
|
||||
{ value: 'pill', label: 'Medicine' },
|
||||
{ value: 'tablets', label: 'Tablets' },
|
||||
{ value: 'bandage', label: 'Bandage' },
|
||||
{ value: 'ambulance', label: 'Ambulance' },
|
||||
{ value: 'heart-pulse', label: 'Health' },
|
||||
{ value: 'activity', label: 'Activity' },
|
||||
{ value: 'cross', label: 'Care' },
|
||||
{ value: 'scissors', label: 'Haircut' },
|
||||
{ value: 'shower-head', label: 'Personal care' },
|
||||
{ value: 'dumbbell', label: 'Sports' },
|
||||
{ value: 'trophy', label: 'Competition' },
|
||||
{ value: 'car', label: 'Car' },
|
||||
{ value: 'bus', label: 'Bus' },
|
||||
{ value: 'train', label: 'Train' },
|
||||
{ value: 'tram-front', label: 'Transit' },
|
||||
{ value: 'fuel', label: 'Fuel' },
|
||||
{ value: 'parking-meter', label: 'Parking' },
|
||||
{ value: 'traffic-cone', label: 'Traffic' },
|
||||
{ value: 'navigation', label: 'Navigation' },
|
||||
{ value: 'route', label: 'Route' },
|
||||
{ value: 'briefcase', label: 'Work' },
|
||||
{ value: 'laptop', label: 'Laptop' },
|
||||
{ value: 'monitor', label: 'Computer' },
|
||||
{ value: 'presentation', label: 'Presentation' },
|
||||
{ value: 'plane', label: 'Travel' },
|
||||
{ value: 'plane-takeoff', label: 'Flight' },
|
||||
{ value: 'school', label: 'School' },
|
||||
{ value: 'graduation-cap', label: 'Education' },
|
||||
{ value: 'book-open', label: 'Reading' },
|
||||
{ value: 'library', label: 'Library' },
|
||||
{ value: 'pencil', label: 'Study' },
|
||||
{ value: 'notebook-pen', label: 'Notes' },
|
||||
{ value: 'calculator', label: 'Calculator' },
|
||||
{ value: 'utensils', label: 'Meal' },
|
||||
{ value: 'cooking-pot', label: 'Cooking' },
|
||||
{ value: 'coffee', label: 'Coffee' },
|
||||
{ value: 'cake', label: 'Birthday' },
|
||||
{ value: 'croissant', label: 'Bakery' },
|
||||
{ value: 'pizza', label: 'Pizza' },
|
||||
{ value: 'ice-cream', label: 'Dessert' },
|
||||
{ value: 'beer', label: 'Bar' },
|
||||
{ value: 'wine', label: 'Wine' },
|
||||
{ value: 'popcorn', label: 'Cinema' },
|
||||
{ value: 'sandwich', label: 'Snack' },
|
||||
{ value: 'salad', label: 'Salad' },
|
||||
{ value: 'shopping-bag', label: 'Shopping' },
|
||||
{ value: 'shopping-cart', label: 'Groceries' },
|
||||
{ value: 'gift', label: 'Gift' },
|
||||
{ value: 'package', label: 'Package' },
|
||||
{ value: 'shirt', label: 'Clothing' },
|
||||
{ value: 'tag', label: 'Tag' },
|
||||
{ value: 'credit-card', label: 'Card' },
|
||||
{ value: 'wallet', label: 'Wallet' },
|
||||
{ value: 'banknote', label: 'Cash' },
|
||||
{ value: 'coins', label: 'Coins' },
|
||||
{ value: 'piggy-bank', label: 'Savings' },
|
||||
{ value: 'receipt', label: 'Receipt' },
|
||||
{ value: 'landmark', label: 'Bank' },
|
||||
{ value: 'music', label: 'Music' },
|
||||
{ value: 'guitar', label: 'Guitar' },
|
||||
{ value: 'film', label: 'Movie' },
|
||||
{ value: 'theater', label: 'Theater' },
|
||||
{ value: 'ticket', label: 'Ticket' },
|
||||
{ value: 'gamepad-2', label: 'Game' },
|
||||
{ value: 'camera', label: 'Photo' },
|
||||
{ value: 'party-popper', label: 'Party' },
|
||||
{ value: 'users', label: 'Family' },
|
||||
{ value: 'baby', label: 'Baby' },
|
||||
{ value: 'dog', label: 'Dog' },
|
||||
{ value: 'cat', label: 'Cat' },
|
||||
{ value: 'paw-print', label: 'Pet' },
|
||||
{ value: 'wrench', label: 'Repair' },
|
||||
{ value: 'hammer', label: 'Maintenance' },
|
||||
{ value: 'paintbrush', label: 'Decoration' },
|
||||
{ value: 'lightbulb', label: 'Idea' },
|
||||
{ value: 'sofa', label: 'Furniture' },
|
||||
{ value: 'bed', label: 'Bed' },
|
||||
{ value: 'bath', label: 'Bath' },
|
||||
{ value: 'washing-machine', label: 'Laundry' },
|
||||
{ value: 'refrigerator', label: 'Fridge' },
|
||||
{ value: 'star', label: 'Favorite' },
|
||||
{ value: 'flag', label: 'Flag' },
|
||||
{ value: 'target', label: 'Goal' },
|
||||
{ value: 'flame', label: 'Important' },
|
||||
{ value: 'leaf', label: 'Nature' },
|
||||
{ value: 'tree-pine', label: 'Outdoors' },
|
||||
{ value: 'flower', label: 'Flower' },
|
||||
{ value: 'sun', label: 'Day' },
|
||||
{ value: 'moon', label: 'Night' },
|
||||
{ value: 'cloud-sun', label: 'Weather' },
|
||||
];
|
||||
|
||||
const HOUR_HEIGHT = 56; // px pro Stunde in Wochen-/Tagesansicht
|
||||
|
||||
/**
|
||||
@@ -136,14 +245,12 @@ function getMondayOf(dateStr) {
|
||||
}
|
||||
|
||||
function formatDate(dateStr, { long = false, weekday = false } = {}) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
const day = d.getDate();
|
||||
const mon = MONTH_NAMES()[d.getMonth()];
|
||||
if (weekday) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
const wd = long ? DAY_NAMES_LONG()[d.getDay()] : DAY_NAMES_SHORT()[d.getDay()];
|
||||
return `${wd}, ${day}. ${mon}`;
|
||||
return `${wd}, ${formatPreferredDate(dateStr)}`;
|
||||
}
|
||||
return `${day}. ${mon} ${d.getFullYear()}`;
|
||||
return formatPreferredDate(dateStr);
|
||||
}
|
||||
|
||||
function formatDateTime(datetimeStr) {
|
||||
@@ -154,6 +261,28 @@ function formatDateTime(datetimeStr) {
|
||||
return time ? `${formatDate(date)} ${time} ${t('calendar.timeSuffix')}`.trimEnd() : formatDate(date);
|
||||
}
|
||||
|
||||
function eventIconName(icon) {
|
||||
const normalized = EVENT_ICON_ALIASES[icon] || icon;
|
||||
return EVENT_ICONS.some((item) => item.value === normalized) ? normalized : 'calendar';
|
||||
}
|
||||
|
||||
function eventIconHtml(icon, className = 'event-icon') {
|
||||
return `<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) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
const year = d.getFullYear();
|
||||
@@ -358,6 +487,7 @@ function renderView() {
|
||||
if (state.view === 'week') renderWeekView(body);
|
||||
if (state.view === 'day') renderDayView(body);
|
||||
if (state.view === 'agenda') renderAgendaView(body);
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
@@ -432,7 +562,7 @@ function renderMonthDay(date, inMonth) {
|
||||
data-id="${ev.id}"
|
||||
style="background-color:${esc(bg)};${fg ? `color:${fg};` : ''}"
|
||||
title="${esc(ev.title)}${ev.cal_name ? ' · ' + ev.cal_name : ''}"
|
||||
>${esc(ev.title)}</div>
|
||||
>${eventIconHtml(ev.icon, 'event-icon event-icon--compact')}<span>${esc(ev.title)}</span></div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
@@ -481,7 +611,7 @@ function renderWeekView(container) {
|
||||
${alldayEvs[i].map((ev) => `
|
||||
<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)};` : ''}"
|
||||
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('')}
|
||||
</div>
|
||||
`).join('')}
|
||||
@@ -553,7 +683,7 @@ function renderWeekEvent(ev) {
|
||||
return `
|
||||
<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)};` : ''}">
|
||||
<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>
|
||||
`;
|
||||
@@ -593,7 +723,7 @@ function renderDayView(container) {
|
||||
${allday.map((ev) => `
|
||||
<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)};` : ''}"
|
||||
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 class="day-view__scroll" id="day-scroll">
|
||||
@@ -689,7 +819,7 @@ function renderAgendaEvent(ev) {
|
||||
<div class="agenda-event" data-id="${ev.id}">
|
||||
<div class="agenda-event__color" style="background-color:${esc(displayColor)};"></div>
|
||||
<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">
|
||||
<span>${timeStr}</span>
|
||||
${ev.location ? `<span>📍 ${esc(fmtLocation(ev.location))}</span>` : ''}
|
||||
@@ -724,7 +854,7 @@ function showEventPopup(ev, anchor) {
|
||||
const displayColor = ev.cal_color || ev.color;
|
||||
popup.innerHTML = `
|
||||
<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">
|
||||
${ev.cal_name ? `<div><span class="event-cal-label" style="--cal-color:${esc(displayColor)}">${esc(ev.cal_name)}</span></div>` : ''}
|
||||
<div>${timeStr}</div>
|
||||
@@ -811,20 +941,53 @@ const REMINDER_OFFSETS = () => [
|
||||
{ value: '15', label: t('reminders.offset15min') },
|
||||
{ value: '60', label: t('reminders.offset1hour') },
|
||||
{ value: '1440', label: t('reminders.offset1day') },
|
||||
{ value: '2880', label: t('reminders.offset2days') },
|
||||
{ value: '10080', label: t('reminders.offset1week') },
|
||||
{ value: '20160', label: t('reminders.offset2weeks') },
|
||||
{ value: 'custom', label: t('reminders.offsetCustom') },
|
||||
];
|
||||
|
||||
function reminderOffsetFromEvent(event, reminder) {
|
||||
if (!reminder || !event?.start_datetime) return '';
|
||||
const remindMs = new Date(reminder.remind_at).getTime();
|
||||
const startMs = new Date(event.start_datetime).getTime();
|
||||
const startMs = new Date(reminderStartValue(event.start_datetime)).getTime();
|
||||
const diffMin = Math.round((startMs - remindMs) / 60000);
|
||||
const opts = [0, 15, 60, 1440];
|
||||
const opts = [0, 15, 60, 1440, 2880, 10080, 20160];
|
||||
const match = opts.find((o) => o === diffMin);
|
||||
return match !== undefined ? String(match) : '';
|
||||
return match !== undefined ? String(match) : 'custom';
|
||||
}
|
||||
|
||||
function customReminderFromEvent(event, reminder) {
|
||||
const fallback = { amount: 1, unit: 'days' };
|
||||
if (!reminder || !event?.start_datetime) return fallback;
|
||||
const diffMin = Math.max(0, Math.round(
|
||||
(new Date(reminderStartValue(event.start_datetime)).getTime() - new Date(reminder.remind_at).getTime()) / 60000
|
||||
));
|
||||
if (diffMin % 10080 === 0 && diffMin >= 10080) return { amount: diffMin / 10080, unit: 'weeks' };
|
||||
if (diffMin % 1440 === 0 && diffMin >= 1440) return { amount: diffMin / 1440, unit: 'days' };
|
||||
if (diffMin % 60 === 0 && diffMin >= 60) return { amount: diffMin / 60, unit: 'hours' };
|
||||
return { amount: Math.max(diffMin, 1), unit: 'minutes' };
|
||||
}
|
||||
|
||||
function customReminderMinutes(amount, unit) {
|
||||
const value = Math.max(parseInt(amount, 10) || 1, 1);
|
||||
if (unit === 'weeks') return value * 10080;
|
||||
if (unit === 'days') return value * 1440;
|
||||
if (unit === 'hours') return value * 60;
|
||||
return value;
|
||||
}
|
||||
|
||||
function reminderStartValue(startDatetime) {
|
||||
return startDatetime?.includes('T') ? startDatetime : `${startDatetime}T09:00`;
|
||||
}
|
||||
|
||||
function toLocalDateTimeString(date) {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
function renderCalendarReminderSection(reminder = null, event = null) {
|
||||
const currentOffset = event ? reminderOffsetFromEvent(event, reminder) : '';
|
||||
const custom = customReminderFromEvent(event, reminder);
|
||||
return `
|
||||
<div class="reminder-section">
|
||||
<div class="form-group" style="margin:0">
|
||||
@@ -835,6 +998,21 @@ function renderCalendarReminderSection(reminder = null, event = null) {
|
||||
).join('')}
|
||||
</select>
|
||||
</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>`;
|
||||
}
|
||||
|
||||
@@ -898,6 +1076,56 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) {
|
||||
});
|
||||
if (isEdit && event?.all_day) { timeFields.style.display = 'none'; alldayFields.style.display = ''; }
|
||||
|
||||
bindDateInputs(panel);
|
||||
|
||||
const iconInput = panel.querySelector('#modal-icon');
|
||||
const iconTrigger = panel.querySelector('#modal-icon-trigger');
|
||||
const iconGrid = panel.querySelector('#modal-icon-grid');
|
||||
const selectIcon = (icon) => {
|
||||
const nextIcon = eventIconName(icon);
|
||||
if (iconInput) iconInput.value = nextIcon;
|
||||
if (iconTrigger) {
|
||||
iconTrigger.dataset.icon = nextIcon;
|
||||
const iconEl = iconTrigger.querySelector('[data-lucide]');
|
||||
iconEl?.setAttribute('data-lucide', nextIcon);
|
||||
}
|
||||
iconGrid?.querySelectorAll('.event-icon-picker__option').forEach((btn) => {
|
||||
const active = btn.dataset.icon === nextIcon;
|
||||
btn.classList.toggle('event-icon-picker__option--active', active);
|
||||
btn.setAttribute('aria-checked', active ? 'true' : 'false');
|
||||
});
|
||||
if (window.lucide) lucide.createIcons();
|
||||
};
|
||||
|
||||
iconTrigger?.addEventListener('click', () => {
|
||||
if (!iconGrid) return;
|
||||
iconGrid.hidden = !iconGrid.hidden;
|
||||
iconTrigger.setAttribute('aria-expanded', iconGrid.hidden ? 'false' : 'true');
|
||||
});
|
||||
iconGrid?.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.event-icon-picker__option');
|
||||
if (!btn) return;
|
||||
selectIcon(btn.dataset.icon);
|
||||
iconGrid.hidden = true;
|
||||
iconTrigger?.setAttribute('aria-expanded', 'false');
|
||||
iconTrigger?.focus();
|
||||
});
|
||||
document.addEventListener('click', function closeIconPicker(e) {
|
||||
if (!panel.isConnected) {
|
||||
document.removeEventListener('click', closeIconPicker);
|
||||
return;
|
||||
}
|
||||
if (iconGrid?.hidden || iconGrid?.contains(e.target) || iconTrigger?.contains(e.target)) return;
|
||||
iconGrid.hidden = true;
|
||||
iconTrigger?.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
const reminderOffset = panel.querySelector('#modal-reminder-offset');
|
||||
const reminderCustom = panel.querySelector('#modal-reminder-custom');
|
||||
reminderOffset?.addEventListener('change', () => {
|
||||
if (reminderCustom) reminderCustom.hidden = reminderOffset.value !== 'custom';
|
||||
});
|
||||
|
||||
panel.querySelector('#modal-cancel').addEventListener('click', closeModal);
|
||||
|
||||
panel.querySelector('#modal-delete')?.addEventListener('click', async () => {
|
||||
@@ -906,6 +1134,7 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) {
|
||||
});
|
||||
|
||||
panel.querySelector('#modal-save').addEventListener('click', () => saveEvent(panel, mode, event?.id, reminder));
|
||||
if (window.lucide) lucide.createIcons();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -920,6 +1149,18 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
|
||||
const endDate = isEdit && event.end_datetime ? localDate(event.end_datetime) : startDate;
|
||||
const endTime = isEdit && event.end_datetime && event.end_datetime.length > 10
|
||||
? localTime(event.end_datetime) : '10:00';
|
||||
const selectedIcon = eventIconName(isEdit ? event.icon : 'calendar');
|
||||
const iconButtons = EVENT_ICONS.map((icon) =>
|
||||
`<button type="button"
|
||||
class="event-icon-picker__option ${selectedIcon === icon.value ? 'event-icon-picker__option--active' : ''}"
|
||||
data-icon="${icon.value}"
|
||||
role="radio"
|
||||
aria-checked="${selectedIcon === icon.value ? 'true' : 'false'}"
|
||||
aria-label="${esc(icon.label)}"
|
||||
title="${esc(icon.label)}">
|
||||
<i data-lucide="${icon.value}" aria-hidden="true"></i>
|
||||
</button>`
|
||||
).join('');
|
||||
|
||||
const userOpts = [
|
||||
`<option value="">${t('calendar.assignedNobody')}</option>`,
|
||||
@@ -929,10 +1170,28 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
|
||||
].join('');
|
||||
|
||||
return `
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="modal-title">${t('calendar.titleLabel')}</label>
|
||||
<input type="text" class="form-input" id="modal-title"
|
||||
placeholder="${t('calendar.titlePlaceholder')}" value="${esc(isEdit ? event.title : '')}">
|
||||
<div class="event-title-picker">
|
||||
<div class="form-group event-icon-picker">
|
||||
<label class="form-label" for="modal-icon-trigger">${t('calendar.iconLabel')}</label>
|
||||
<input type="hidden" id="modal-icon" value="${selectedIcon}">
|
||||
<button type="button"
|
||||
class="event-icon-picker__trigger"
|
||||
id="modal-icon-trigger"
|
||||
data-icon="${selectedIcon}"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
aria-label="${t('calendar.iconLabel')}">
|
||||
<i data-lucide="${selectedIcon}" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-group event-title-picker__title">
|
||||
<label class="form-label" for="modal-title">${t('calendar.titleLabel')}</label>
|
||||
<input type="text" class="form-input" id="modal-title"
|
||||
placeholder="${t('calendar.titlePlaceholder')}" value="${esc(isEdit ? event.title : '')}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="event-icon-picker__grid" id="modal-icon-grid" role="radiogroup" aria-label="${t('calendar.iconLabel')}" hidden>
|
||||
${iconButtons}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -947,7 +1206,7 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
|
||||
<div class="modal-grid modal-grid--2">
|
||||
<div class="form-group">
|
||||
<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 class="form-group">
|
||||
<label class="form-label" for="modal-start-time">${t('calendar.startTimeLabel')}</label>
|
||||
@@ -957,7 +1216,7 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
|
||||
<div class="modal-grid modal-grid--2">
|
||||
<div class="form-group">
|
||||
<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 class="form-group">
|
||||
<label class="form-label" for="modal-end-time">${t('calendar.endTimeLabel')}</label>
|
||||
@@ -970,11 +1229,11 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
|
||||
<div class="modal-grid modal-grid--2">
|
||||
<div class="form-group">
|
||||
<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 class="form-group">
|
||||
<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>
|
||||
@@ -1035,6 +1294,7 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null) {
|
||||
|
||||
const allday = overlay.querySelector('#modal-allday').checked;
|
||||
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 assigned_to = overlay.querySelector('#modal-assigned').value || null;
|
||||
const description = overlay.querySelector('#modal-description').value.trim() || null;
|
||||
@@ -1042,18 +1302,27 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null) {
|
||||
let start_datetime, end_datetime;
|
||||
|
||||
if (allday) {
|
||||
start_datetime = overlay.querySelector('#modal-allday-start')?.value
|
||||
|| overlay.querySelector('#modal-start-date').value;
|
||||
end_datetime = overlay.querySelector('#modal-allday-end')?.value
|
||||
|| overlay.querySelector('#modal-end-date').value;
|
||||
start_datetime = readDateInput(overlay, '#modal-allday-start')
|
||||
|| readDateInput(overlay, '#modal-start-date');
|
||||
end_datetime = readDateInput(overlay, '#modal-allday-end')
|
||||
|| readDateInput(overlay, '#modal-end-date');
|
||||
end_datetime = end_datetime || null;
|
||||
} 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 ed = overlay.querySelector('#modal-end-date').value;
|
||||
const ed = readDateInput(overlay, '#modal-end-date');
|
||||
const et = overlay.querySelector('#modal-end-time').value;
|
||||
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;
|
||||
@@ -1061,10 +1330,16 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null) {
|
||||
|
||||
try {
|
||||
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 = {
|
||||
title, description, start_datetime, end_datetime,
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -1086,9 +1361,14 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null) {
|
||||
|
||||
if (offsetVal !== '' && offsetVal !== undefined) {
|
||||
// Remind-Zeitpunkt = start_datetime - offset (in Minuten)
|
||||
const startMs = new Date(start_datetime).getTime();
|
||||
const offsetMs = parseInt(offsetVal, 10) * 60000;
|
||||
const remindAt = new Date(startMs - offsetMs).toISOString().slice(0, 16);
|
||||
const startMs = new Date(reminderStartValue(start_datetime)).getTime();
|
||||
const offsetMinutes = offsetVal === 'custom'
|
||||
? 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 });
|
||||
refreshReminders();
|
||||
} else {
|
||||
@@ -1136,4 +1416,3 @@ async function deleteEvent(id) {
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
|
||||
+15
-4
@@ -7,7 +7,7 @@
|
||||
import { api } from '/api.js';
|
||||
import { openModal as openSharedModal, closeModal as closeSharedModal, selectModal } from '/components/modal.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 { DEFAULT_CATEGORY_NAME, categoryLabel } from '/utils/shopping-categories.js';
|
||||
|
||||
@@ -680,6 +680,12 @@ function openMealModal(opts) {
|
||||
recipeSelect.value = String(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', () => {
|
||||
const tmp = document.createElement('div');
|
||||
@@ -750,7 +756,7 @@ function buildModalContent({ mode, date, mealType, meal }) {
|
||||
<div class="modal-grid modal-grid--2">
|
||||
<div class="form-group">
|
||||
<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 class="form-group">
|
||||
<label class="form-label" for="modal-type">${t('meals.mealTypeLabel')}</label>
|
||||
@@ -850,13 +856,19 @@ function closeModal({ force = false } = {}) {
|
||||
|
||||
async function saveModal(overlay) {
|
||||
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 title = overlay.querySelector('#modal-title').value.trim();
|
||||
const notes = overlay.querySelector('#modal-notes').value.trim() || null;
|
||||
const recipe_url = overlay.querySelector('#modal-recipe-url').value.trim() || 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) {
|
||||
window.oikos?.showToast(t('meals.titleRequired'), 'error');
|
||||
return;
|
||||
@@ -979,4 +991,3 @@ async function transferMeal(mealId) {
|
||||
// --------------------------------------------------------
|
||||
// Hilfsfunktion
|
||||
// --------------------------------------------------------
|
||||
|
||||
|
||||
+23
-7
@@ -8,7 +8,7 @@ import { api } from '/api.js';
|
||||
import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js';
|
||||
import { openModal as openSharedModal, closeModal, wireBlurValidation, validateAll, btnSuccess, btnError, promptModal } from '/components/modal.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 { 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="form-group">
|
||||
<label class="label" for="task-due-date">${t('tasks.dueDateLabel')}</label>
|
||||
<input class="input" type="date" id="task-due-date" name="due_date"
|
||||
value="${task?.due_date ?? ''}">
|
||||
<input class="input js-date-input" type="text" id="task-due-date" name="due_date"
|
||||
value="${formatDateInput(task?.due_date)}" placeholder="${dateInputPlaceholder()}" inputmode="numeric">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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 class="form-group" style="margin:0">
|
||||
<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 class="form-group" style="margin:0">
|
||||
<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', () => {
|
||||
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
|
||||
panel.querySelector('#task-form')
|
||||
@@ -527,13 +533,25 @@ async function handleFormSubmit(e, container) {
|
||||
|
||||
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 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 = {
|
||||
title: form.title.value.trim(),
|
||||
description: form.description.value.trim() || null,
|
||||
priority: form.priority.value,
|
||||
category: form.category.value,
|
||||
due_date: form.due_date?.value || null,
|
||||
due_date: dueDate || null,
|
||||
due_time: form.due_time?.value || null,
|
||||
assigned_to: form.assigned_to.value ? Number(form.assigned_to.value) : null,
|
||||
is_recurring: rrule.is_recurring ? 1 : 0,
|
||||
@@ -554,8 +572,6 @@ async function handleFormSubmit(e, container) {
|
||||
|
||||
// Erinnerung speichern oder löschen
|
||||
if (savedTaskId) {
|
||||
const reminderToggle = form.querySelector('#reminder-toggle');
|
||||
const reminderDate = form.querySelector('#reminder-date')?.value;
|
||||
const reminderTime = form.querySelector('#reminder-time')?.value || '08:00';
|
||||
|
||||
if (reminderToggle?.checked && reminderDate) {
|
||||
|
||||
+14
-3
@@ -4,7 +4,7 @@
|
||||
* Abhängigkeiten: /i18n.js
|
||||
*/
|
||||
|
||||
import { t } from '/i18n.js';
|
||||
import { t, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid } from '/i18n.js';
|
||||
|
||||
const FREQ_OPTIONS = () => [
|
||||
{ value: '', label: t('rrule.freqNone') },
|
||||
@@ -115,7 +115,8 @@ export function renderRRuleFields(prefix, existingRule) {
|
||||
|
||||
<div class="form-group" style="margin-top:var(--space-3)">
|
||||
<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>
|
||||
@@ -153,6 +154,13 @@ export function bindRRuleEvents(root, prefix) {
|
||||
|
||||
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
|
||||
root.querySelectorAll(`#${prefix}-rrule-weekdays .rrule-day`).forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
@@ -177,7 +185,9 @@ export function bindRRuleEvents(root, prefix) {
|
||||
export function getRRuleValues(root, prefix) {
|
||||
const freq = root.querySelector(`#${prefix}-rrule-freq`)?.value || '';
|
||||
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 = [];
|
||||
root.querySelectorAll(`#${prefix}-rrule-weekdays .rrule-day--active`).forEach(btn => {
|
||||
@@ -188,5 +198,6 @@ export function getRRuleValues(root, prefix) {
|
||||
return {
|
||||
is_recurring: !!rule,
|
||||
recurrence_rule: rule,
|
||||
valid_until: isDateInputValid(untilRaw),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -210,6 +210,9 @@
|
||||
}
|
||||
|
||||
.month-day__event {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
padding: var(--space-px) var(--space-1);
|
||||
@@ -224,6 +227,28 @@
|
||||
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 {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
@@ -381,6 +406,9 @@
|
||||
}
|
||||
|
||||
.week-event__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -506,6 +534,9 @@
|
||||
}
|
||||
|
||||
.agenda-event__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
white-space: nowrap;
|
||||
@@ -513,6 +544,104 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.event-title-picker {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.event-icon-picker {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.event-title-picker__title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.event-icon-picker__trigger {
|
||||
width: 52px;
|
||||
height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast), background-color var(--transition-fast), transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.event-icon-picker__trigger:hover,
|
||||
.event-icon-picker__trigger:focus-visible {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.event-icon-picker__trigger:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.event-icon-picker__trigger i {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.event-icon-picker__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(44px, 1fr));
|
||||
gap: var(--space-2);
|
||||
margin: calc(var(--space-2) * -1) 0 var(--space-4) 0;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
|
||||
.event-icon-picker__grid[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.event-icon-picker__option {
|
||||
height: 42px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast), color var(--transition-fast), background-color var(--transition-fast), transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.event-icon-picker__option:hover,
|
||||
.event-icon-picker__option:focus-visible {
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.event-icon-picker__option--active {
|
||||
color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.event-icon-picker__option i {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.reminder-custom {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.reminder-custom[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.agenda-event__meta {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
@@ -608,6 +737,9 @@
|
||||
}
|
||||
|
||||
.event-popup__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
margin-bottom: var(--space-2);
|
||||
@@ -647,6 +779,9 @@
|
||||
}
|
||||
|
||||
.allday-event {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
padding: var(--space-px) var(--space-1);
|
||||
@@ -660,6 +795,12 @@
|
||||
filter: saturate(0.4);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.event-title-picker {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Kalender-Name-Label (Agenda, Popup)
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
+4
-4
@@ -13,10 +13,10 @@
|
||||
* → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit)
|
||||
*/
|
||||
|
||||
const SHELL_CACHE = 'oikos-shell-v56';
|
||||
const PAGES_CACHE = 'oikos-pages-v51';
|
||||
const LOCALES_CACHE = 'oikos-locales-v3';
|
||||
const ASSETS_CACHE = 'oikos-assets-v51';
|
||||
const SHELL_CACHE = 'oikos-shell-v59';
|
||||
const PAGES_CACHE = 'oikos-pages-v54';
|
||||
const LOCALES_CACHE = 'oikos-locales-v5';
|
||||
const ASSETS_CACHE = 'oikos-assets-v54';
|
||||
const BYPASS_CACHE = 'oikos-bypass-flag';
|
||||
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,
|
||||
location TEXT,
|
||||
color TEXT NOT NULL DEFAULT '#007AFF',
|
||||
icon TEXT NOT NULL DEFAULT 'calendar',
|
||||
assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
external_calendar_id TEXT,
|
||||
@@ -266,6 +267,7 @@ const MIGRATIONS_SQL = {
|
||||
all_day INTEGER NOT NULL DEFAULT 0,
|
||||
location TEXT,
|
||||
color TEXT NOT NULL DEFAULT '#007AFF',
|
||||
icon TEXT NOT NULL DEFAULT 'calendar',
|
||||
assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
external_calendar_id TEXT,
|
||||
@@ -322,6 +324,12 @@ const MIGRATIONS_SQL = {
|
||||
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);
|
||||
`,
|
||||
14: `
|
||||
ALTER TABLE calendar_events ADD COLUMN icon TEXT NOT NULL DEFAULT 'calendar';
|
||||
`,
|
||||
15: `
|
||||
UPDATE calendar_events SET icon = 'drill' WHERE icon = 'tooth';
|
||||
`,
|
||||
};
|
||||
|
||||
export { MIGRATIONS_SQL };
|
||||
|
||||
@@ -734,6 +734,20 @@ const MIGRATIONS = [
|
||||
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';
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 22,
|
||||
description: 'Normalize calendar dentist icon',
|
||||
up: `
|
||||
UPDATE calendar_events SET icon = 'drill' WHERE icon = 'tooth';
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,24 @@ const router = express.Router();
|
||||
|
||||
const VALID_SOURCES = ['local', 'google', 'apple', 'ics'];
|
||||
const ICS_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
|
||||
const VALID_EVENT_ICONS = new Set([
|
||||
'calendar', 'drill', 'alarm-clock', 'clock', 'bell', 'map-pin', 'home',
|
||||
'house', 'building', 'hospital', 'stethoscope', 'syringe', 'pill',
|
||||
'tablets', 'bandage', 'ambulance', 'heart-pulse', 'activity', 'cross',
|
||||
'scissors', 'shower-head', 'dumbbell', 'trophy', 'car', 'bus', 'train',
|
||||
'tram-front', 'plane', 'plane-takeoff', 'fuel', 'parking-meter',
|
||||
'traffic-cone', 'navigation', 'route', 'briefcase', 'laptop', 'monitor',
|
||||
'presentation', 'school', 'graduation-cap', 'book-open', 'library',
|
||||
'pencil', 'notebook-pen', 'calculator', 'utensils', 'cooking-pot',
|
||||
'coffee', 'cake', 'croissant', 'pizza', 'ice-cream', 'beer', 'wine',
|
||||
'popcorn', 'sandwich', 'salad', 'shopping-bag', 'shopping-cart', 'gift',
|
||||
'package', 'shirt', 'tag', 'credit-card', 'wallet', 'banknote', 'coins',
|
||||
'piggy-bank', 'receipt', 'landmark', 'music', 'guitar', 'film', 'theater',
|
||||
'ticket', 'gamepad-2', 'camera', 'party-popper', 'users', 'baby', 'dog',
|
||||
'cat', 'paw-print', 'wrench', 'hammer', 'paintbrush', 'lightbulb', 'sofa',
|
||||
'bed', 'bath', 'washing-machine', 'refrigerator', 'star', 'flag', 'target',
|
||||
'flame', 'leaf', 'tree-pine', 'flower', 'sun', 'moon', 'cloud-sun',
|
||||
]);
|
||||
|
||||
function getUserId(req) {
|
||||
const candidates = [req.authUserId, req.user?.id, req.session?.userId];
|
||||
@@ -35,6 +53,12 @@ function isAdminUser(req) {
|
||||
return req.authRole === 'admin' || req.session?.isAdmin === true || req.session?.role === 'admin';
|
||||
}
|
||||
|
||||
function eventIcon(value) {
|
||||
const raw = typeof value === 'string' && value.trim() ? value.trim() : 'calendar';
|
||||
const icon = raw === 'tooth' ? 'drill' : raw;
|
||||
return VALID_EVENT_ICONS.has(icon) ? icon : null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// RRULE-Expansion: alle Vorkommen eines wiederkehrenden Events
|
||||
// innerhalb [from, to] generieren (inklusive beider Grenzen).
|
||||
@@ -531,7 +555,7 @@ router.get('/:id', (req, res) => {
|
||||
// POST /api/v1/calendar
|
||||
// Neuen Termin anlegen.
|
||||
// Body: { title, description?, start_datetime, end_datetime?,
|
||||
// all_day?, location?, color?, assigned_to?,
|
||||
// all_day?, location?, color?, icon?, assigned_to?,
|
||||
// recurrence_rule? }
|
||||
// Response: { data: Event }
|
||||
// --------------------------------------------------------
|
||||
@@ -553,10 +577,12 @@ router.post('/', (req, res) => {
|
||||
const vStart = datetime(req.body.start_datetime, 'Startdatum', true);
|
||||
const vEnd = datetime(req.body.end_datetime, 'Enddatum');
|
||||
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 vRrule = rrule(req.body.recurrence_rule, 'Wiederholung');
|
||||
const errors = collectErrors([vTitle, vDesc, vStart, vEnd, vColor, vLoc, vRrule]);
|
||||
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;
|
||||
|
||||
@@ -568,13 +594,13 @@ router.post('/', (req, res) => {
|
||||
const result = db.get().prepare(`
|
||||
INSERT INTO calendar_events
|
||||
(title, description, start_datetime, end_datetime, all_day,
|
||||
location, color, assigned_to, created_by, recurrence_rule)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
location, color, icon, assigned_to, created_by, recurrence_rule)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
vTitle.value, vDesc.value,
|
||||
vStart.value, vEnd.value,
|
||||
all_day ? 1 : 0, vLoc.value,
|
||||
vColor.value, assigned_to || null,
|
||||
vColor.value, vIcon, assigned_to || null,
|
||||
userId, vRrule.value
|
||||
);
|
||||
|
||||
@@ -618,6 +644,8 @@ router.put('/:id', (req, res) => {
|
||||
if (req.body.recurrence_rule !== undefined) checks.push(rrule(req.body.recurrence_rule, 'Wiederholung'));
|
||||
const errors = collectErrors(checks);
|
||||
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 {
|
||||
title, description, start_datetime, end_datetime,
|
||||
@@ -635,6 +663,7 @@ router.put('/:id', (req, res) => {
|
||||
all_day = COALESCE(?, all_day),
|
||||
location = ?,
|
||||
color = COALESCE(?, color),
|
||||
icon = COALESCE(?, icon),
|
||||
assigned_to = ?,
|
||||
recurrence_rule = ?,
|
||||
user_modified = ?
|
||||
@@ -647,6 +676,7 @@ router.put('/:id', (req, res) => {
|
||||
all_day !== undefined ? (all_day ? 1 : 0) : null,
|
||||
location !== undefined ? (location || null) : event.location,
|
||||
colorVal ?? null,
|
||||
req.body.icon !== undefined ? vIcon : null,
|
||||
assigned_to !== undefined ? (assigned_to || null) : event.assigned_to,
|
||||
recurrence_rule !== undefined ? (recurrence_rule || null) : event.recurrence_rule,
|
||||
userModified,
|
||||
|
||||
@@ -65,6 +65,7 @@ function syncBirthdayCalendarEvent(database, birthday) {
|
||||
all_day: 1,
|
||||
location: null,
|
||||
color: BIRTHDAY_COLOR,
|
||||
icon: 'cake',
|
||||
assigned_to: null,
|
||||
recurrence_rule: BIRTHDAY_RRULE,
|
||||
created_by: birthday.created_by,
|
||||
@@ -76,7 +77,7 @@ function syncBirthdayCalendarEvent(database, birthday) {
|
||||
database.prepare(`
|
||||
UPDATE calendar_events
|
||||
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'
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
@@ -87,6 +88,7 @@ function syncBirthdayCalendarEvent(database, birthday) {
|
||||
payload.all_day,
|
||||
payload.location,
|
||||
payload.color,
|
||||
payload.icon,
|
||||
payload.assigned_to,
|
||||
payload.recurrence_rule,
|
||||
payload.created_by,
|
||||
@@ -99,8 +101,8 @@ function syncBirthdayCalendarEvent(database, birthday) {
|
||||
const result = database.prepare(`
|
||||
INSERT INTO calendar_events
|
||||
(title, description, start_datetime, end_datetime, all_day, location, color,
|
||||
assigned_to, created_by, recurrence_rule, external_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'local')
|
||||
icon, assigned_to, created_by, recurrence_rule, external_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'local')
|
||||
`).run(
|
||||
payload.title,
|
||||
payload.description,
|
||||
@@ -109,6 +111,7 @@ function syncBirthdayCalendarEvent(database, birthday) {
|
||||
payload.all_day,
|
||||
payload.location,
|
||||
payload.color,
|
||||
payload.icon,
|
||||
payload.assigned_to,
|
||||
payload.created_by,
|
||||
payload.recurrence_rule,
|
||||
|
||||
@@ -92,6 +92,11 @@ test('Termin abrufen (mit assigned_name via JOIN)', () => {
|
||||
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)', () => {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user