feat: add reminders for tasks and calendar events (closes #13)
- DB migration #8: reminders table (entity_type, entity_id, remind_at, dismissed, created_by) - REST API: GET /pending, GET /?entity, POST /, PATCH /:id/dismiss, DELETE - Client polling module (reminders.js): 60s interval, toast + Browser Notification API - Tasks: enable reminder with custom date/time in edit modal - Calendar: reminder offset selector (at time / 15min / 1h / 1d before) - Bell badge shows pending count; reminders auto-dismiss after 30s or on user action - SW shell cache updated to include reminders.js + reminders.css - 11 new DB tests covering CRUD, pending query, dismiss, upsert, cascade delete, constraints
This commit is contained in:
@@ -10,6 +10,7 @@ import { openModal as openSharedModal, closeModal, confirmModal } from '/compone
|
||||
import { stagger } from '/utils/ux.js';
|
||||
import { t, formatTime } from '/i18n.js';
|
||||
import { esc } from '/utils/html.js';
|
||||
import { refresh as refreshReminders } from '/reminders.js';
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Konstanten
|
||||
@@ -695,9 +696,10 @@ function showEventPopup(ev, anchor) {
|
||||
popup.style.top = `${Math.max(8, top)}px`;
|
||||
popup.style.left = `${Math.max(8, left)}px`;
|
||||
|
||||
popup.querySelector('#popup-edit').addEventListener('click', () => {
|
||||
popup.querySelector('#popup-edit').addEventListener('click', async () => {
|
||||
popup.remove();
|
||||
openEventModal({ mode: 'edit', event: ev });
|
||||
const reminder = await loadReminderForEvent(ev.id);
|
||||
openEventModal({ mode: 'edit', event: ev, reminder });
|
||||
});
|
||||
|
||||
popup.querySelector('#popup-delete').addEventListener('click', async () => {
|
||||
@@ -717,13 +719,59 @@ function showEventPopup(ev, anchor) {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Reminder-Helfer für Kalender-Events
|
||||
// --------------------------------------------------------
|
||||
|
||||
async function loadReminderForEvent(eventId) {
|
||||
try {
|
||||
const data = await api.get(`/reminders?entity_type=event&entity_id=${eventId}`);
|
||||
return data.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const REMINDER_OFFSETS = () => [
|
||||
{ value: '', label: t('reminders.offsetNone') },
|
||||
{ value: '0', label: t('reminders.offsetAtTime') },
|
||||
{ value: '15', label: t('reminders.offset15min') },
|
||||
{ value: '60', label: t('reminders.offset1hour') },
|
||||
{ value: '1440', label: t('reminders.offset1day') },
|
||||
];
|
||||
|
||||
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 diffMin = Math.round((startMs - remindMs) / 60000);
|
||||
const opts = [0, 15, 60, 1440];
|
||||
const match = opts.find((o) => o === diffMin);
|
||||
return match !== undefined ? String(match) : '';
|
||||
}
|
||||
|
||||
function renderCalendarReminderSection(reminder = null, event = null) {
|
||||
const currentOffset = event ? reminderOffsetFromEvent(event, reminder) : '';
|
||||
return `
|
||||
<div class="reminder-section">
|
||||
<div class="form-group" style="margin:0">
|
||||
<label class="form-label" for="modal-reminder-offset">${t('reminders.offsetLabel')}</label>
|
||||
<select class="form-input" id="modal-reminder-offset" style="min-height:44px">
|
||||
${REMINDER_OFFSETS().map((o) =>
|
||||
`<option value="${o.value}" ${currentOffset === o.value ? 'selected' : ''}>${esc(o.label)}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Event-Modal (Erstellen / Bearbeiten)
|
||||
// --------------------------------------------------------
|
||||
|
||||
function openEventModal({ mode, event = null, date = null }) {
|
||||
function openEventModal({ mode, event = null, date = null, reminder = null }) {
|
||||
const isEdit = mode === 'edit';
|
||||
const content = buildEventModalContent({ mode, event, date });
|
||||
const content = buildEventModalContent({ mode, event, date, reminder });
|
||||
|
||||
openSharedModal({
|
||||
title: isEdit ? t('calendar.editEvent') : t('calendar.newEvent'),
|
||||
@@ -764,12 +812,12 @@ function openEventModal({ mode, event = null, date = null }) {
|
||||
await deleteEvent(event.id);
|
||||
});
|
||||
|
||||
panel.querySelector('#modal-save').addEventListener('click', () => saveEvent(panel, mode, event?.id));
|
||||
panel.querySelector('#modal-save').addEventListener('click', () => saveEvent(panel, mode, event?.id, reminder));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildEventModalContent({ mode, event, date }) {
|
||||
function buildEventModalContent({ mode, event, date, reminder = null }) {
|
||||
const isEdit = mode === 'edit';
|
||||
const today = date || state.today;
|
||||
|
||||
@@ -867,6 +915,8 @@ function buildEventModalContent({ mode, event, date }) {
|
||||
|
||||
${renderRRuleFields('event', isEdit ? event.recurrence_rule : null)}
|
||||
|
||||
${renderCalendarReminderSection(reminder, event)}
|
||||
|
||||
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
|
||||
${isEdit ? `<button class="btn btn--danger btn--icon" id="modal-delete" aria-label="${t('calendar.deleteEvent')}">
|
||||
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
|
||||
@@ -878,7 +928,7 @@ function buildEventModalContent({ mode, event, date }) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function saveEvent(overlay, mode, eventId) {
|
||||
async function saveEvent(overlay, mode, eventId, existingReminder = null) {
|
||||
const saveBtn = overlay.querySelector('#modal-save');
|
||||
const title = overlay.querySelector('#modal-title').value.trim();
|
||||
|
||||
@@ -922,15 +972,35 @@ async function saveEvent(overlay, mode, eventId) {
|
||||
recurrence_rule: rrule.recurrence_rule,
|
||||
};
|
||||
|
||||
let savedEventId = eventId;
|
||||
if (mode === 'create') {
|
||||
const res = await api.post('/calendar', body);
|
||||
state.events.push(res.data);
|
||||
savedEventId = res.data?.id;
|
||||
} else {
|
||||
const res = await api.put(`/calendar/${eventId}`, body);
|
||||
const idx = state.events.findIndex((e) => e.id === eventId);
|
||||
if (idx !== -1) state.events[idx] = res.data;
|
||||
}
|
||||
|
||||
// Erinnerung speichern oder löschen
|
||||
if (savedEventId) {
|
||||
const offsetSel = overlay.querySelector('#modal-reminder-offset');
|
||||
const offsetVal = offsetSel?.value;
|
||||
|
||||
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);
|
||||
await api.post('/reminders', { entity_type: 'event', entity_id: savedEventId, remind_at: remindAt });
|
||||
refreshReminders();
|
||||
} else {
|
||||
api.delete(`/reminders?entity_type=event&entity_id=${savedEventId}`).catch(() => {});
|
||||
refreshReminders();
|
||||
}
|
||||
}
|
||||
|
||||
closeModal();
|
||||
renderView();
|
||||
window.oikos?.showToast(mode === 'create' ? t('calendar.createdToast') : t('calendar.savedToast'), 'success');
|
||||
@@ -944,6 +1014,8 @@ async function saveEvent(overlay, mode, eventId) {
|
||||
async function deleteEvent(id) {
|
||||
try {
|
||||
await api.delete(`/calendar/${id}`);
|
||||
api.delete(`/reminders?entity_type=event&entity_id=${id}`).catch(() => {});
|
||||
refreshReminders();
|
||||
state.events = state.events.filter((e) => e.id !== id);
|
||||
renderView();
|
||||
window.oikos?.showToast(t('calendar.deletedToast'), 'success');
|
||||
|
||||
+89
-10
@@ -10,6 +10,7 @@ import { openModal as openSharedModal, closeModal, wireBlurValidation, validateA
|
||||
import { stagger, vibrate } from '/utils/ux.js';
|
||||
import { t, formatDate } from '/i18n.js';
|
||||
import { esc } from '/utils/html.js';
|
||||
import { refresh as refreshReminders } from '/reminders.js';
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Konstanten
|
||||
@@ -243,7 +244,7 @@ function renderTaskGroups(tasks, groupMode) {
|
||||
// Task-Modal (Erstellen / Bearbeiten)
|
||||
// --------------------------------------------------------
|
||||
|
||||
function renderModalContent({ task = null, users = [] } = {}) {
|
||||
function renderModalContent({ task = null, users = [], reminder = null } = {}) {
|
||||
const isEdit = !!task;
|
||||
|
||||
const userOptions = users.map((u) =>
|
||||
@@ -334,6 +335,8 @@ function renderModalContent({ task = null, users = [] } = {}) {
|
||||
|
||||
${renderRRuleFields('task', task?.recurrence_rule)}
|
||||
|
||||
${renderReminderSection(reminder)}
|
||||
|
||||
<div id="task-form-error" class="login-error" hidden></div>
|
||||
|
||||
<div class="modal-panel__footer" style="padding:0;border:none;margin-top:var(--space-6)">
|
||||
@@ -392,15 +395,51 @@ async function loadTaskForEdit(id) {
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async function loadReminderForTask(taskId) {
|
||||
try {
|
||||
const data = await api.get(`/reminders?entity_type=task&entity_id=${taskId}`);
|
||||
return data.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderReminderSection(reminder = null) {
|
||||
const hasReminder = !!reminder;
|
||||
const remindDate = hasReminder ? reminder.remind_at.slice(0, 10) : '';
|
||||
const remindTime = hasReminder ? reminder.remind_at.slice(11, 16) : '';
|
||||
|
||||
return `
|
||||
<div class="reminder-section">
|
||||
<div class="reminder-section__header">
|
||||
<label class="toggle" style="margin:0">
|
||||
<input type="checkbox" id="reminder-toggle" ${hasReminder ? 'checked' : ''}>
|
||||
<span class="toggle__track"></span>
|
||||
<span class="reminder-section__title">${t('reminders.enableLabel')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<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}">
|
||||
</div>
|
||||
<div class="form-group" style="margin:0">
|
||||
<label class="label" for="reminder-time">${t('reminders.timeLabel')}</label>
|
||||
<input class="input" type="time" id="reminder-time" value="${remindTime || '08:00'}">
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Modal-Verwaltung (delegiert an Shared Modal-System)
|
||||
// --------------------------------------------------------
|
||||
|
||||
function openTaskModal({ task = null, users = [] } = {}, container) {
|
||||
function openTaskModal({ task = null, users = [], reminder = null } = {}, container) {
|
||||
const isEdit = !!task;
|
||||
openSharedModal({
|
||||
title: isEdit ? t('tasks.editTask') : t('tasks.newTask'),
|
||||
content: renderModalContent({ task, users }),
|
||||
content: renderModalContent({ task, users, reminder }),
|
||||
size: 'lg',
|
||||
onSave(panel) {
|
||||
// RRULE-Events binden
|
||||
@@ -409,6 +448,13 @@ function openTaskModal({ task = null, users = [] } = {}, container) {
|
||||
// Blur-Validierung für required-Felder aktivieren
|
||||
wireBlurValidation(panel);
|
||||
|
||||
// Reminder-Toggle: Felder ein-/ausblenden
|
||||
const toggle = panel.querySelector('#reminder-toggle');
|
||||
const fields = panel.querySelector('#reminder-fields');
|
||||
toggle?.addEventListener('change', () => {
|
||||
fields.style.display = toggle.checked ? '' : 'none';
|
||||
});
|
||||
|
||||
// Form-Events
|
||||
panel.querySelector('#task-form')
|
||||
?.addEventListener('submit', (e) => handleFormSubmit(e, container));
|
||||
@@ -454,13 +500,34 @@ async function handleFormSubmit(e, container) {
|
||||
if (form.status) body.status = form.status.value;
|
||||
|
||||
try {
|
||||
let savedTaskId = taskId;
|
||||
if (taskId) {
|
||||
await api.put(`/tasks/${taskId}`, body);
|
||||
window.oikos.showToast(t('tasks.savedToast'), 'success');
|
||||
} else {
|
||||
await api.post('/tasks', body);
|
||||
const res = await api.post('/tasks', body);
|
||||
savedTaskId = res.data?.id;
|
||||
window.oikos.showToast(t('tasks.createdToast'), 'success');
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const remindAt = `${reminderDate}T${reminderTime}`;
|
||||
await api.post('/reminders', { entity_type: 'task', entity_id: savedTaskId, remind_at: remindAt });
|
||||
refreshReminders();
|
||||
} else if (!reminderToggle?.checked) {
|
||||
try {
|
||||
await api.delete(`/reminders?entity_type=task&entity_id=${savedTaskId}`);
|
||||
refreshReminders();
|
||||
} catch { /* kein Reminder vorhanden - ignorieren */ }
|
||||
}
|
||||
}
|
||||
|
||||
btnSuccess(submitBtn, originalLabel);
|
||||
setTimeout(() => closeModal(), 700);
|
||||
await loadTasks(container);
|
||||
@@ -477,6 +544,9 @@ async function handleDeleteTask(id, container) {
|
||||
if (!await confirmModal(t('tasks.deleteConfirm'), { danger: true, confirmLabel: t('common.delete') })) return;
|
||||
try {
|
||||
await api.delete(`/tasks/${id}`);
|
||||
// Erinnerungen für diese Aufgabe ebenfalls entfernen
|
||||
api.delete(`/reminders?entity_type=task&entity_id=${id}`).catch(() => {});
|
||||
refreshReminders();
|
||||
closeModal();
|
||||
window.oikos.showToast(t('tasks.deletedToast'), 'default');
|
||||
await loadTasks(container);
|
||||
@@ -670,8 +740,11 @@ function wireKanbanDrag(container) {
|
||||
const card = e.target.closest('.kanban-card[data-task-id]');
|
||||
if (!card) return;
|
||||
try {
|
||||
const task = await loadTaskForEdit(card.dataset.taskId);
|
||||
openTaskModal({ task, users: state.users }, container);
|
||||
const [task, reminder] = await Promise.all([
|
||||
loadTaskForEdit(card.dataset.taskId),
|
||||
loadReminderForTask(card.dataset.taskId),
|
||||
]);
|
||||
openTaskModal({ task, users: state.users, reminder }, container);
|
||||
} catch (err) {
|
||||
window.oikos.showToast(t('tasks.loadError'), 'danger');
|
||||
}
|
||||
@@ -880,8 +953,11 @@ function wireSwipeGestures(container) {
|
||||
resetCard(true);
|
||||
vibrate(20);
|
||||
try {
|
||||
const task = await loadTaskForEdit(taskId);
|
||||
openTaskModal({ task, users: state.users }, container);
|
||||
const [task, reminder] = await Promise.all([
|
||||
loadTaskForEdit(taskId),
|
||||
loadReminderForTask(taskId),
|
||||
]);
|
||||
openTaskModal({ task, users: state.users, reminder }, container);
|
||||
} catch (err) {
|
||||
window.oikos.showToast(t('tasks.loadError'), 'danger');
|
||||
}
|
||||
@@ -1013,8 +1089,11 @@ function wireTaskList(container) {
|
||||
|
||||
if (action === 'edit-task' || action === 'open-task') {
|
||||
try {
|
||||
const task = await loadTaskForEdit(id);
|
||||
openTaskModal({ task, users: state.users }, container);
|
||||
const [task, reminder] = await Promise.all([
|
||||
loadTaskForEdit(id),
|
||||
loadReminderForTask(id),
|
||||
]);
|
||||
openTaskModal({ task, users: state.users, reminder }, container);
|
||||
} catch (err) {
|
||||
window.oikos.showToast(t('tasks.loadError'), 'danger');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user