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:
ulsklyc
2026-04-28 07:45:43 +02:00
committed by GitHub
29 changed files with 887 additions and 124 deletions
+50
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+14 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+12 -4
View File
@@ -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
View File
@@ -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;
+311 -32
View File
@@ -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,11 +1170,29 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
].join('');
return `
<div class="form-group">
<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">
<label class="toggle">
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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),
};
}
+141
View File
@@ -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
View File
@@ -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];
+8
View File
@@ -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 };
+14
View File
@@ -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';
`,
},
];
/**
+34 -4
View File
@@ -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,
+6 -3
View File
@@ -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,
+5
View File
@@ -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);