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:
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.20.0] - 2026-04-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Reminders: set time-based reminders on tasks and calendar events (closes #13)
|
||||||
|
- Tasks: enable a reminder with a custom date and time via the task edit modal
|
||||||
|
- Calendar events: choose an offset (at time, 15 min, 1 hour, or 1 day before) via the event edit dialog
|
||||||
|
- In-app toast notifications (built via DOM API, no external dependencies) appear when a reminder is due
|
||||||
|
- Browser Notification API support - reminders fire as system notifications when permission is granted
|
||||||
|
- Client-side polling every 60 seconds checks for pending reminders
|
||||||
|
- Reminders can be dismissed individually; dismissed reminders no longer appear
|
||||||
|
- Bell badge on each reminder shows pending count when reminders are due
|
||||||
|
- DB migration #8 adds `reminders` table with `entity_type`, `entity_id`, `remind_at`, `dismissed` fields and appropriate indexes
|
||||||
|
|
||||||
## [0.19.6] - 2026-04-15
|
## [0.19.6] - 2026-04-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oikos",
|
"name": "oikos",
|
||||||
"version": "0.19.6",
|
"version": "0.20.0",
|
||||||
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
|
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -20,7 +20,8 @@
|
|||||||
"test:ncb": "node --experimental-sqlite test-notes-contacts-budget.js",
|
"test:ncb": "node --experimental-sqlite test-notes-contacts-budget.js",
|
||||||
"test:ux-utils": "node test-ux-utils.js",
|
"test:ux-utils": "node test-ux-utils.js",
|
||||||
"test:modal-utils": "node --loader ./test-browser-loader.mjs test-modal-utils.js",
|
"test:modal-utils": "node --loader ./test-browser-loader.mjs test-modal-utils.js",
|
||||||
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils"
|
"test:reminders": "node --experimental-sqlite test-reminders.js",
|
||||||
|
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
<link rel="stylesheet" href="/styles/layout.css" />
|
<link rel="stylesheet" href="/styles/layout.css" />
|
||||||
<link rel="stylesheet" href="/styles/glass.css" />
|
<link rel="stylesheet" href="/styles/glass.css" />
|
||||||
<link rel="stylesheet" href="/styles/login.css" />
|
<link rel="stylesheet" href="/styles/login.css" />
|
||||||
|
<link rel="stylesheet" href="/styles/reminders.css" />
|
||||||
|
|
||||||
<!-- Theme: Vor CSS-Rendering anwenden (Flash-Prevention) -->
|
<!-- Theme: Vor CSS-Rendering anwenden (Flash-Prevention) -->
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -597,5 +597,26 @@
|
|||||||
"unitWeeks": "Wochen",
|
"unitWeeks": "Wochen",
|
||||||
"unitMonth": "Monat",
|
"unitMonth": "Monat",
|
||||||
"unitMonths": "Monate"
|
"unitMonths": "Monate"
|
||||||
|
},
|
||||||
|
"reminders": {
|
||||||
|
"sectionTitle": "Erinnerung",
|
||||||
|
"enableLabel": "Erinnerung setzen",
|
||||||
|
"dateLabel": "Datum",
|
||||||
|
"timeLabel": "Uhrzeit",
|
||||||
|
"offsetLabel": "Erinnern",
|
||||||
|
"offsetNone": "Keine",
|
||||||
|
"offset15min": "15 Minuten vorher",
|
||||||
|
"offset1hour": "1 Stunde vorher",
|
||||||
|
"offset1day": "1 Tag vorher",
|
||||||
|
"offsetAtTime": "Zum Startzeitpunkt",
|
||||||
|
"toastTitle": "Erinnerung",
|
||||||
|
"dismiss": "Verwerfen",
|
||||||
|
"notificationPermission": "Browser-Benachrichtigungen",
|
||||||
|
"notificationEnable": "Benachrichtigungen aktivieren",
|
||||||
|
"notificationEnabled": "Benachrichtigungen aktiv",
|
||||||
|
"notificationDenied": "Benachrichtigungen blockiert",
|
||||||
|
"notificationHint": "Erhalte Benachrichtigungen auch wenn die App geöffnet ist.",
|
||||||
|
"pendingBadgeTitle": "{{count}} fällige Erinnerung",
|
||||||
|
"pendingBadgeTitlePlural": "{{count}} fällige Erinnerungen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -597,5 +597,26 @@
|
|||||||
"unitWeeks": "weeks",
|
"unitWeeks": "weeks",
|
||||||
"unitMonth": "month",
|
"unitMonth": "month",
|
||||||
"unitMonths": "months"
|
"unitMonths": "months"
|
||||||
|
},
|
||||||
|
"reminders": {
|
||||||
|
"sectionTitle": "Reminder",
|
||||||
|
"enableLabel": "Set reminder",
|
||||||
|
"dateLabel": "Date",
|
||||||
|
"timeLabel": "Time",
|
||||||
|
"offsetLabel": "Remind me",
|
||||||
|
"offsetNone": "None",
|
||||||
|
"offset15min": "15 minutes before",
|
||||||
|
"offset1hour": "1 hour before",
|
||||||
|
"offset1day": "1 day before",
|
||||||
|
"offsetAtTime": "At event time",
|
||||||
|
"toastTitle": "Reminder",
|
||||||
|
"dismiss": "Dismiss",
|
||||||
|
"notificationPermission": "Browser notifications",
|
||||||
|
"notificationEnable": "Enable notifications",
|
||||||
|
"notificationEnabled": "Notifications active",
|
||||||
|
"notificationDenied": "Notifications blocked",
|
||||||
|
"notificationHint": "Receive notifications while the app is open.",
|
||||||
|
"pendingBadgeTitle": "{{count}} reminder due",
|
||||||
|
"pendingBadgeTitlePlural": "{{count}} reminders due"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@ import { openModal as openSharedModal, closeModal, confirmModal } from '/compone
|
|||||||
import { stagger } from '/utils/ux.js';
|
import { stagger } from '/utils/ux.js';
|
||||||
import { t, formatTime } from '/i18n.js';
|
import { t, formatTime } from '/i18n.js';
|
||||||
import { esc } from '/utils/html.js';
|
import { esc } from '/utils/html.js';
|
||||||
|
import { refresh as refreshReminders } from '/reminders.js';
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Konstanten
|
// Konstanten
|
||||||
@@ -695,9 +696,10 @@ function showEventPopup(ev, anchor) {
|
|||||||
popup.style.top = `${Math.max(8, top)}px`;
|
popup.style.top = `${Math.max(8, top)}px`;
|
||||||
popup.style.left = `${Math.max(8, left)}px`;
|
popup.style.left = `${Math.max(8, left)}px`;
|
||||||
|
|
||||||
popup.querySelector('#popup-edit').addEventListener('click', () => {
|
popup.querySelector('#popup-edit').addEventListener('click', async () => {
|
||||||
popup.remove();
|
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 () => {
|
popup.querySelector('#popup-delete').addEventListener('click', async () => {
|
||||||
@@ -717,13 +719,59 @@ function showEventPopup(ev, anchor) {
|
|||||||
}, 0);
|
}, 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)
|
// 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 isEdit = mode === 'edit';
|
||||||
const content = buildEventModalContent({ mode, event, date });
|
const content = buildEventModalContent({ mode, event, date, reminder });
|
||||||
|
|
||||||
openSharedModal({
|
openSharedModal({
|
||||||
title: isEdit ? t('calendar.editEvent') : t('calendar.newEvent'),
|
title: isEdit ? t('calendar.editEvent') : t('calendar.newEvent'),
|
||||||
@@ -764,12 +812,12 @@ function openEventModal({ mode, event = null, date = null }) {
|
|||||||
await deleteEvent(event.id);
|
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 isEdit = mode === 'edit';
|
||||||
const today = date || state.today;
|
const today = date || state.today;
|
||||||
|
|
||||||
@@ -867,6 +915,8 @@ function buildEventModalContent({ mode, event, date }) {
|
|||||||
|
|
||||||
${renderRRuleFields('event', isEdit ? event.recurrence_rule : null)}
|
${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)">
|
<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')}">
|
${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>
|
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
|
||||||
@@ -878,7 +928,7 @@ function buildEventModalContent({ mode, event, date }) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveEvent(overlay, mode, eventId) {
|
async function saveEvent(overlay, mode, eventId, existingReminder = null) {
|
||||||
const saveBtn = overlay.querySelector('#modal-save');
|
const saveBtn = overlay.querySelector('#modal-save');
|
||||||
const title = overlay.querySelector('#modal-title').value.trim();
|
const title = overlay.querySelector('#modal-title').value.trim();
|
||||||
|
|
||||||
@@ -922,15 +972,35 @@ async function saveEvent(overlay, mode, eventId) {
|
|||||||
recurrence_rule: rrule.recurrence_rule,
|
recurrence_rule: rrule.recurrence_rule,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let savedEventId = eventId;
|
||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
const res = await api.post('/calendar', body);
|
const res = await api.post('/calendar', body);
|
||||||
state.events.push(res.data);
|
state.events.push(res.data);
|
||||||
|
savedEventId = res.data?.id;
|
||||||
} else {
|
} else {
|
||||||
const res = await api.put(`/calendar/${eventId}`, body);
|
const res = await api.put(`/calendar/${eventId}`, body);
|
||||||
const idx = state.events.findIndex((e) => e.id === eventId);
|
const idx = state.events.findIndex((e) => e.id === eventId);
|
||||||
if (idx !== -1) state.events[idx] = res.data;
|
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();
|
closeModal();
|
||||||
renderView();
|
renderView();
|
||||||
window.oikos?.showToast(mode === 'create' ? t('calendar.createdToast') : t('calendar.savedToast'), 'success');
|
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) {
|
async function deleteEvent(id) {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/calendar/${id}`);
|
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);
|
state.events = state.events.filter((e) => e.id !== id);
|
||||||
renderView();
|
renderView();
|
||||||
window.oikos?.showToast(t('calendar.deletedToast'), 'success');
|
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 { stagger, vibrate } from '/utils/ux.js';
|
||||||
import { t, formatDate } from '/i18n.js';
|
import { t, formatDate } from '/i18n.js';
|
||||||
import { esc } from '/utils/html.js';
|
import { esc } from '/utils/html.js';
|
||||||
|
import { refresh as refreshReminders } from '/reminders.js';
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Konstanten
|
// Konstanten
|
||||||
@@ -243,7 +244,7 @@ function renderTaskGroups(tasks, groupMode) {
|
|||||||
// Task-Modal (Erstellen / Bearbeiten)
|
// Task-Modal (Erstellen / Bearbeiten)
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
function renderModalContent({ task = null, users = [] } = {}) {
|
function renderModalContent({ task = null, users = [], reminder = null } = {}) {
|
||||||
const isEdit = !!task;
|
const isEdit = !!task;
|
||||||
|
|
||||||
const userOptions = users.map((u) =>
|
const userOptions = users.map((u) =>
|
||||||
@@ -334,6 +335,8 @@ function renderModalContent({ task = null, users = [] } = {}) {
|
|||||||
|
|
||||||
${renderRRuleFields('task', task?.recurrence_rule)}
|
${renderRRuleFields('task', task?.recurrence_rule)}
|
||||||
|
|
||||||
|
${renderReminderSection(reminder)}
|
||||||
|
|
||||||
<div id="task-form-error" class="login-error" hidden></div>
|
<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)">
|
<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;
|
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)
|
// Modal-Verwaltung (delegiert an Shared Modal-System)
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
function openTaskModal({ task = null, users = [] } = {}, container) {
|
function openTaskModal({ task = null, users = [], reminder = null } = {}, container) {
|
||||||
const isEdit = !!task;
|
const isEdit = !!task;
|
||||||
openSharedModal({
|
openSharedModal({
|
||||||
title: isEdit ? t('tasks.editTask') : t('tasks.newTask'),
|
title: isEdit ? t('tasks.editTask') : t('tasks.newTask'),
|
||||||
content: renderModalContent({ task, users }),
|
content: renderModalContent({ task, users, reminder }),
|
||||||
size: 'lg',
|
size: 'lg',
|
||||||
onSave(panel) {
|
onSave(panel) {
|
||||||
// RRULE-Events binden
|
// RRULE-Events binden
|
||||||
@@ -409,6 +448,13 @@ function openTaskModal({ task = null, users = [] } = {}, container) {
|
|||||||
// Blur-Validierung für required-Felder aktivieren
|
// Blur-Validierung für required-Felder aktivieren
|
||||||
wireBlurValidation(panel);
|
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
|
// Form-Events
|
||||||
panel.querySelector('#task-form')
|
panel.querySelector('#task-form')
|
||||||
?.addEventListener('submit', (e) => handleFormSubmit(e, container));
|
?.addEventListener('submit', (e) => handleFormSubmit(e, container));
|
||||||
@@ -454,13 +500,34 @@ async function handleFormSubmit(e, container) {
|
|||||||
if (form.status) body.status = form.status.value;
|
if (form.status) body.status = form.status.value;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let savedTaskId = taskId;
|
||||||
if (taskId) {
|
if (taskId) {
|
||||||
await api.put(`/tasks/${taskId}`, body);
|
await api.put(`/tasks/${taskId}`, body);
|
||||||
window.oikos.showToast(t('tasks.savedToast'), 'success');
|
window.oikos.showToast(t('tasks.savedToast'), 'success');
|
||||||
} else {
|
} else {
|
||||||
await api.post('/tasks', body);
|
const res = await api.post('/tasks', body);
|
||||||
|
savedTaskId = res.data?.id;
|
||||||
window.oikos.showToast(t('tasks.createdToast'), 'success');
|
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);
|
btnSuccess(submitBtn, originalLabel);
|
||||||
setTimeout(() => closeModal(), 700);
|
setTimeout(() => closeModal(), 700);
|
||||||
await loadTasks(container);
|
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;
|
if (!await confirmModal(t('tasks.deleteConfirm'), { danger: true, confirmLabel: t('common.delete') })) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/tasks/${id}`);
|
await api.delete(`/tasks/${id}`);
|
||||||
|
// Erinnerungen für diese Aufgabe ebenfalls entfernen
|
||||||
|
api.delete(`/reminders?entity_type=task&entity_id=${id}`).catch(() => {});
|
||||||
|
refreshReminders();
|
||||||
closeModal();
|
closeModal();
|
||||||
window.oikos.showToast(t('tasks.deletedToast'), 'default');
|
window.oikos.showToast(t('tasks.deletedToast'), 'default');
|
||||||
await loadTasks(container);
|
await loadTasks(container);
|
||||||
@@ -670,8 +740,11 @@ function wireKanbanDrag(container) {
|
|||||||
const card = e.target.closest('.kanban-card[data-task-id]');
|
const card = e.target.closest('.kanban-card[data-task-id]');
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
try {
|
try {
|
||||||
const task = await loadTaskForEdit(card.dataset.taskId);
|
const [task, reminder] = await Promise.all([
|
||||||
openTaskModal({ task, users: state.users }, container);
|
loadTaskForEdit(card.dataset.taskId),
|
||||||
|
loadReminderForTask(card.dataset.taskId),
|
||||||
|
]);
|
||||||
|
openTaskModal({ task, users: state.users, reminder }, container);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.oikos.showToast(t('tasks.loadError'), 'danger');
|
window.oikos.showToast(t('tasks.loadError'), 'danger');
|
||||||
}
|
}
|
||||||
@@ -880,8 +953,11 @@ function wireSwipeGestures(container) {
|
|||||||
resetCard(true);
|
resetCard(true);
|
||||||
vibrate(20);
|
vibrate(20);
|
||||||
try {
|
try {
|
||||||
const task = await loadTaskForEdit(taskId);
|
const [task, reminder] = await Promise.all([
|
||||||
openTaskModal({ task, users: state.users }, container);
|
loadTaskForEdit(taskId),
|
||||||
|
loadReminderForTask(taskId),
|
||||||
|
]);
|
||||||
|
openTaskModal({ task, users: state.users, reminder }, container);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.oikos.showToast(t('tasks.loadError'), 'danger');
|
window.oikos.showToast(t('tasks.loadError'), 'danger');
|
||||||
}
|
}
|
||||||
@@ -1013,8 +1089,11 @@ function wireTaskList(container) {
|
|||||||
|
|
||||||
if (action === 'edit-task' || action === 'open-task') {
|
if (action === 'edit-task' || action === 'open-task') {
|
||||||
try {
|
try {
|
||||||
const task = await loadTaskForEdit(id);
|
const [task, reminder] = await Promise.all([
|
||||||
openTaskModal({ task, users: state.users }, container);
|
loadTaskForEdit(id),
|
||||||
|
loadReminderForTask(id),
|
||||||
|
]);
|
||||||
|
openTaskModal({ task, users: state.users, reminder }, container);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.oikos.showToast(t('tasks.loadError'), 'danger');
|
window.oikos.showToast(t('tasks.loadError'), 'danger');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
/**
|
||||||
|
* Modul: Erinnerungen (Reminders)
|
||||||
|
* Zweck: Clientseitiges Polling für fällige Erinnerungen, Browser-Benachrichtigungen,
|
||||||
|
* In-App-Toasts und Bell-Badge-Aktualisierung.
|
||||||
|
* Abhängigkeiten: /api.js, /i18n.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { api } from '/api.js';
|
||||||
|
import { t } from '/i18n.js';
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Konfiguration
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 60_000; // 1 Minute
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Zustand
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
let _pollTimer = null;
|
||||||
|
let _shownIds = new Set(); // bereits angezeigte Reminder-IDs in dieser Session
|
||||||
|
let _isInitialized = false;
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Browser-Benachrichtigungen
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktuellen Benachrichtigungs-Permission-Status zurückgeben.
|
||||||
|
* @returns {'granted'|'denied'|'default'|'unsupported'}
|
||||||
|
*/
|
||||||
|
function notificationStatus() {
|
||||||
|
if (!('Notification' in window)) return 'unsupported';
|
||||||
|
return Notification.permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browser-Benachrichtigung anfordern.
|
||||||
|
* @returns {Promise<'granted'|'denied'|'default'>}
|
||||||
|
*/
|
||||||
|
async function requestPermission() {
|
||||||
|
if (!('Notification' in window)) return 'unsupported';
|
||||||
|
if (Notification.permission === 'granted') return 'granted';
|
||||||
|
return Notification.requestPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt eine native Browser-Benachrichtigung an.
|
||||||
|
* @param {string} title
|
||||||
|
* @param {string} body
|
||||||
|
*/
|
||||||
|
function showBrowserNotification(title, body) {
|
||||||
|
if (!('Notification' in window) || Notification.permission !== 'granted') return;
|
||||||
|
try {
|
||||||
|
const n = new Notification(title, { body, icon: '/icons/icon-192.png' });
|
||||||
|
setTimeout(() => n.close(), 8000);
|
||||||
|
} catch {
|
||||||
|
// Notification-API kann in bestimmten Kontexten fehlschlagen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Bell-Badge (Sidebar / Bottom-Nav)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert den Badge-Zähler am Bell-Icon in der Navigation.
|
||||||
|
* @param {number} count
|
||||||
|
*/
|
||||||
|
function updateBellBadge(count) {
|
||||||
|
document.querySelectorAll('.reminder-bell-badge').forEach((badge) => {
|
||||||
|
if (count > 0) {
|
||||||
|
badge.textContent = count > 9 ? '9+' : String(count);
|
||||||
|
badge.hidden = false;
|
||||||
|
} else {
|
||||||
|
badge.hidden = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// SVG-Helfer (DOM-API, kein innerHTML)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
function createBellSvg() {
|
||||||
|
const NS = 'http://www.w3.org/2000/svg';
|
||||||
|
const svg = document.createElementNS(NS, 'svg');
|
||||||
|
svg.setAttribute('viewBox', '0 0 24 24');
|
||||||
|
svg.setAttribute('fill', 'none');
|
||||||
|
svg.setAttribute('stroke', 'currentColor');
|
||||||
|
svg.setAttribute('stroke-width', '2');
|
||||||
|
svg.setAttribute('width', '16');
|
||||||
|
svg.setAttribute('height', '16');
|
||||||
|
svg.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
|
const path1 = document.createElementNS(NS, 'path');
|
||||||
|
path1.setAttribute('d', 'M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9');
|
||||||
|
const path2 = document.createElementNS(NS, 'path');
|
||||||
|
path2.setAttribute('d', 'M13.73 21a2 2 0 0 1-3.46 0');
|
||||||
|
|
||||||
|
svg.appendChild(path1);
|
||||||
|
svg.appendChild(path2);
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Erinnerungen anzeigen
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verarbeitet eine Liste fälliger Erinnerungen und zeigt Toast + Browser-Notification.
|
||||||
|
* @param {Array} reminders
|
||||||
|
*/
|
||||||
|
function processReminders(reminders) {
|
||||||
|
const newOnes = reminders.filter((r) => !_shownIds.has(r.id));
|
||||||
|
if (!newOnes.length) return;
|
||||||
|
|
||||||
|
newOnes.forEach((reminder) => {
|
||||||
|
_shownIds.add(reminder.id);
|
||||||
|
showReminderToast(reminder);
|
||||||
|
showBrowserNotification(
|
||||||
|
t('reminders.toastTitle'),
|
||||||
|
reminder.entity_title || ''
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt einen persistenten Toast für eine Erinnerung mit Verwerfen-Button.
|
||||||
|
* @param {{ id: number, entity_title: string }} reminder
|
||||||
|
*/
|
||||||
|
function showReminderToast(reminder) {
|
||||||
|
const container = document.getElementById('toast-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const existing = container.querySelectorAll('.toast');
|
||||||
|
if (existing.length >= 3) existing[0].remove();
|
||||||
|
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'toast toast--reminder';
|
||||||
|
toast.setAttribute('role', 'alert');
|
||||||
|
toast.dataset.reminderId = reminder.id;
|
||||||
|
|
||||||
|
const iconWrap = document.createElement('span');
|
||||||
|
iconWrap.className = 'toast__reminder-icon';
|
||||||
|
iconWrap.setAttribute('aria-hidden', 'true');
|
||||||
|
iconWrap.appendChild(createBellSvg());
|
||||||
|
|
||||||
|
const textSpan = document.createElement('span');
|
||||||
|
textSpan.className = 'toast__reminder-text';
|
||||||
|
|
||||||
|
const titleEl = document.createElement('strong');
|
||||||
|
titleEl.textContent = t('reminders.toastTitle');
|
||||||
|
|
||||||
|
const sep = document.createTextNode(': ');
|
||||||
|
|
||||||
|
const bodyEl = document.createElement('span');
|
||||||
|
bodyEl.textContent = reminder.entity_title || '';
|
||||||
|
|
||||||
|
textSpan.appendChild(titleEl);
|
||||||
|
textSpan.appendChild(sep);
|
||||||
|
textSpan.appendChild(bodyEl);
|
||||||
|
|
||||||
|
const dismissBtn = document.createElement('button');
|
||||||
|
dismissBtn.className = 'toast__undo';
|
||||||
|
dismissBtn.textContent = t('reminders.dismiss');
|
||||||
|
dismissBtn.addEventListener('click', () => {
|
||||||
|
dismissReminder(reminder.id);
|
||||||
|
toast.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.appendChild(iconWrap);
|
||||||
|
toast.appendChild(textSpan);
|
||||||
|
toast.appendChild(dismissBtn);
|
||||||
|
container.appendChild(toast);
|
||||||
|
|
||||||
|
// Reminder-Toasts bleiben 30 Sekunden sichtbar
|
||||||
|
const dismissTimer = setTimeout(() => {
|
||||||
|
toast.classList.add('toast--out');
|
||||||
|
toast.addEventListener('animationend', () => toast.remove(), { once: true });
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
toast.addEventListener('click', (e) => {
|
||||||
|
if (e.target === dismissBtn) return;
|
||||||
|
clearTimeout(dismissTimer);
|
||||||
|
dismissReminder(reminder.id);
|
||||||
|
toast.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// API-Aktionen
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwirft eine Erinnerung serverseitig.
|
||||||
|
* @param {number} id
|
||||||
|
*/
|
||||||
|
async function dismissReminder(id) {
|
||||||
|
try {
|
||||||
|
await api.patch(`/reminders/${id}/dismiss`, {});
|
||||||
|
_shownIds.delete(id);
|
||||||
|
} catch {
|
||||||
|
// Netzwerkfehler ignorieren
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt fällige Erinnerungen vom Server und verarbeitet sie.
|
||||||
|
*/
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
const data = await api.get('/reminders/pending');
|
||||||
|
const reminders = data.data ?? [];
|
||||||
|
updateBellBadge(reminders.length);
|
||||||
|
processReminders(reminders);
|
||||||
|
} catch {
|
||||||
|
// Polling-Fehler ignorieren (kann Offline-Zustand sein)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Öffentliche API
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet das Reminder-Polling. Idempotent.
|
||||||
|
*/
|
||||||
|
function init() {
|
||||||
|
if (_isInitialized) return;
|
||||||
|
_isInitialized = true;
|
||||||
|
poll();
|
||||||
|
_pollTimer = setInterval(poll, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stoppt das Polling (z.B. nach Logout).
|
||||||
|
*/
|
||||||
|
function stop() {
|
||||||
|
if (_pollTimer) {
|
||||||
|
clearInterval(_pollTimer);
|
||||||
|
_pollTimer = null;
|
||||||
|
}
|
||||||
|
_isInitialized = false;
|
||||||
|
_shownIds.clear();
|
||||||
|
updateBellBadge(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzwingt sofortigen Poll (z.B. nach Erstellen einer Erinnerung).
|
||||||
|
*/
|
||||||
|
function refresh() {
|
||||||
|
poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { init, stop, refresh, requestPermission, notificationStatus };
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { auth } from '/api.js';
|
import { auth } from '/api.js';
|
||||||
import { initI18n, getLocale, t } from '/i18n.js';
|
import { initI18n, getLocale, t } from '/i18n.js';
|
||||||
|
import { init as initReminders, stop as stopReminders } from '/reminders.js';
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Routen-Definitionen
|
// Routen-Definitionen
|
||||||
@@ -144,6 +145,7 @@ async function navigate(path, userOrPushState = true, pushState = true) {
|
|||||||
// Überlastung: navigate(path, user) nach Login vs navigate(path, false) beim Init
|
// Überlastung: navigate(path, user) nach Login vs navigate(path, false) beim Init
|
||||||
if (typeof userOrPushState === 'object' && userOrPushState !== null) {
|
if (typeof userOrPushState === 'object' && userOrPushState !== null) {
|
||||||
currentUser = userOrPushState;
|
currentUser = userOrPushState;
|
||||||
|
initReminders();
|
||||||
} else {
|
} else {
|
||||||
pushState = userOrPushState;
|
pushState = userOrPushState;
|
||||||
}
|
}
|
||||||
@@ -159,6 +161,7 @@ async function navigate(path, userOrPushState = true, pushState = true) {
|
|||||||
try {
|
try {
|
||||||
const result = await auth.me();
|
const result = await auth.me();
|
||||||
currentUser = result.user;
|
currentUser = result.user;
|
||||||
|
initReminders();
|
||||||
} catch {
|
} catch {
|
||||||
currentPath = null; // Reset damit navigate('/login') nicht geblockt wird
|
currentPath = null; // Reset damit navigate('/login') nicht geblockt wird
|
||||||
isNavigating = false;
|
isNavigating = false;
|
||||||
@@ -507,6 +510,7 @@ window.addEventListener('popstate', (e) => {
|
|||||||
// Session abgelaufen
|
// Session abgelaufen
|
||||||
window.addEventListener('auth:expired', () => {
|
window.addEventListener('auth:expired', () => {
|
||||||
currentUser = null;
|
currentUser = null;
|
||||||
|
stopReminders();
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/* --------------------------------------------------------
|
||||||
|
Modul: Erinnerungen (Reminders)
|
||||||
|
Zweck: Bell-Badge in der Navigation, Reminder-Toast-Styling
|
||||||
|
-------------------------------------------------------- */
|
||||||
|
|
||||||
|
/* Bell-Badge: Rote Zahl über dem Bell-Icon */
|
||||||
|
.nav-item--reminder {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-bell-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: var(--radius-full, 9999px);
|
||||||
|
background: var(--color-priority-urgent, #EF4444);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reminder-Toast: Bell-Icon + Textblock */
|
||||||
|
.toast--reminder {
|
||||||
|
gap: var(--space-2, 8px);
|
||||||
|
cursor: pointer;
|
||||||
|
padding-right: var(--space-2, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast__reminder-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--color-accent, #2563EB);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast__reminder-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast__reminder-text strong {
|
||||||
|
font-size: var(--text-xs, 0.75rem);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast__reminder-text span {
|
||||||
|
font-size: var(--text-sm, 0.875rem);
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reminder-Abschnitt in Modals */
|
||||||
|
.reminder-section {
|
||||||
|
border-top: 1px solid var(--color-border, rgba(0,0,0,0.1));
|
||||||
|
padding-top: var(--space-4, 16px);
|
||||||
|
margin-top: var(--space-4, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-section__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2, 8px);
|
||||||
|
margin-bottom: var(--space-3, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-section__title {
|
||||||
|
font-size: var(--text-sm, 0.875rem);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-3, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.reminder-fields {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -12,7 +12,7 @@
|
|||||||
* API: Immer Netzwerk (kein Caching von Nutzerdaten)
|
* API: Immer Netzwerk (kein Caching von Nutzerdaten)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const SHELL_CACHE = 'oikos-shell-v31';
|
const SHELL_CACHE = 'oikos-shell-v32';
|
||||||
const PAGES_CACHE = 'oikos-pages-v28';
|
const PAGES_CACHE = 'oikos-pages-v28';
|
||||||
const ASSETS_CACHE = 'oikos-assets-v27';
|
const ASSETS_CACHE = 'oikos-assets-v27';
|
||||||
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE];
|
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE];
|
||||||
@@ -31,6 +31,7 @@ const APP_SHELL = [
|
|||||||
'/locales/ar.json',
|
'/locales/ar.json',
|
||||||
'/locales/hi.json',
|
'/locales/hi.json',
|
||||||
'/locales/pt.json',
|
'/locales/pt.json',
|
||||||
|
'/reminders.js',
|
||||||
'/sw-register.js',
|
'/sw-register.js',
|
||||||
'/lucide.min.js',
|
'/lucide.min.js',
|
||||||
'/styles/tokens.css',
|
'/styles/tokens.css',
|
||||||
@@ -39,6 +40,7 @@ const APP_SHELL = [
|
|||||||
'/styles/layout.css',
|
'/styles/layout.css',
|
||||||
'/styles/glass.css',
|
'/styles/glass.css',
|
||||||
'/styles/login.css',
|
'/styles/login.css',
|
||||||
|
'/styles/reminders.css',
|
||||||
'/styles/dashboard.css',
|
'/styles/dashboard.css',
|
||||||
'/styles/tasks.css',
|
'/styles/tasks.css',
|
||||||
'/styles/shopping.css',
|
'/styles/shopping.css',
|
||||||
|
|||||||
@@ -178,6 +178,20 @@ const MIGRATIONS_SQL = {
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_calendar_external_id ON calendar_events(external_calendar_id);
|
CREATE INDEX IF NOT EXISTS idx_calendar_external_id ON calendar_events(external_calendar_id);
|
||||||
`,
|
`,
|
||||||
|
8: `
|
||||||
|
CREATE TABLE IF NOT EXISTS reminders (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
entity_type TEXT NOT NULL CHECK(entity_type IN ('task', 'event')),
|
||||||
|
entity_id INTEGER NOT NULL,
|
||||||
|
remind_at TEXT NOT NULL,
|
||||||
|
dismissed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reminders_entity ON reminders(entity_type, entity_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reminders_remind ON reminders(remind_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reminders_user ON reminders(created_by);
|
||||||
|
`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export { MIGRATIONS_SQL };
|
export { MIGRATIONS_SQL };
|
||||||
|
|||||||
@@ -369,6 +369,25 @@ const MIGRATIONS = [
|
|||||||
ALTER TABLE meal_ingredients ADD COLUMN category TEXT NOT NULL DEFAULT 'Sonstiges';
|
ALTER TABLE meal_ingredients ADD COLUMN category TEXT NOT NULL DEFAULT 'Sonstiges';
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
version: 8,
|
||||||
|
description: 'Erinnerungen (Reminders) für Aufgaben und Kalender-Events',
|
||||||
|
up: `
|
||||||
|
CREATE TABLE IF NOT EXISTS reminders (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
entity_type TEXT NOT NULL CHECK(entity_type IN ('task', 'event')),
|
||||||
|
entity_id INTEGER NOT NULL,
|
||||||
|
remind_at TEXT NOT NULL,
|
||||||
|
dismissed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reminders_entity ON reminders(entity_type, entity_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reminders_remind ON reminders(remind_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reminders_user ON reminders(created_by);
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import contactsRouter from './routes/contacts.js';
|
|||||||
import budgetRouter from './routes/budget.js';
|
import budgetRouter from './routes/budget.js';
|
||||||
import weatherRouter from './routes/weather.js';
|
import weatherRouter from './routes/weather.js';
|
||||||
import preferencesRouter from './routes/preferences.js';
|
import preferencesRouter from './routes/preferences.js';
|
||||||
|
import remindersRouter from './routes/reminders.js';
|
||||||
|
|
||||||
const log = createLogger('Server');
|
const log = createLogger('Server');
|
||||||
const logSync = createLogger('Sync');
|
const logSync = createLogger('Sync');
|
||||||
@@ -165,6 +166,7 @@ app.use('/api/v1/contacts', contactsRouter);
|
|||||||
app.use('/api/v1/budget', budgetRouter);
|
app.use('/api/v1/budget', budgetRouter);
|
||||||
app.use('/api/v1/weather', weatherRouter);
|
app.use('/api/v1/weather', weatherRouter);
|
||||||
app.use('/api/v1/preferences', preferencesRouter);
|
app.use('/api/v1/preferences', preferencesRouter);
|
||||||
|
app.use('/api/v1/reminders', remindersRouter);
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Health-Check (für Docker)
|
// Health-Check (für Docker)
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* Modul: Erinnerungen (Reminders)
|
||||||
|
* Zweck: REST-API für Erinnerungen an Aufgaben und Kalender-Events
|
||||||
|
* Abhängigkeiten: express, server/db.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createLogger } from '../logger.js';
|
||||||
|
import express from 'express';
|
||||||
|
import * as db from '../db.js';
|
||||||
|
import * as v from '../middleware/validate.js';
|
||||||
|
|
||||||
|
const log = createLogger('Reminders');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const VALID_ENTITY_TYPES = ['task', 'event'];
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// GET /api/v1/reminders/pending
|
||||||
|
// Gibt alle fälligen, nicht-verworfenen Erinnerungen des aktuellen Nutzers zurück.
|
||||||
|
// "Fällig" = remind_at <= jetzt
|
||||||
|
// Response: { data: Reminder[] }
|
||||||
|
// --------------------------------------------------------
|
||||||
|
router.get('/pending', (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const rows = db.get().prepare(`
|
||||||
|
SELECT
|
||||||
|
r.*,
|
||||||
|
CASE r.entity_type
|
||||||
|
WHEN 'task' THEN (SELECT title FROM tasks WHERE id = r.entity_id)
|
||||||
|
WHEN 'event' THEN (SELECT title FROM calendar_events WHERE id = r.entity_id)
|
||||||
|
END AS entity_title
|
||||||
|
FROM reminders r
|
||||||
|
WHERE r.created_by = ?
|
||||||
|
AND r.dismissed = 0
|
||||||
|
AND r.remind_at <= ?
|
||||||
|
ORDER BY r.remind_at ASC
|
||||||
|
`).all(userId, now);
|
||||||
|
|
||||||
|
res.json({ data: rows });
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Fehler beim Laden fälliger Erinnerungen:', err.message);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler.', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// GET /api/v1/reminders?entity_type=task&entity_id=5
|
||||||
|
// Gibt die Erinnerung für eine spezifische Entität zurück (oder null).
|
||||||
|
// Response: { data: Reminder | null }
|
||||||
|
// --------------------------------------------------------
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
const entityType = req.query.entity_type;
|
||||||
|
const entityId = parseInt(req.query.entity_id, 10);
|
||||||
|
|
||||||
|
if (!VALID_ENTITY_TYPES.includes(entityType) || !entityId) {
|
||||||
|
return res.status(400).json({ error: 'entity_type und entity_id sind erforderlich.', code: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = db.get().prepare(`
|
||||||
|
SELECT * FROM reminders
|
||||||
|
WHERE entity_type = ? AND entity_id = ? AND created_by = ? AND dismissed = 0
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
`).get(entityType, entityId, userId);
|
||||||
|
|
||||||
|
res.json({ data: row || null });
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Fehler beim Laden der Erinnerung:', err.message);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler.', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// POST /api/v1/reminders
|
||||||
|
// Erstellt oder ersetzt die Erinnerung für eine Entität.
|
||||||
|
// Body: { entity_type, entity_id, remind_at }
|
||||||
|
// Response: { data: Reminder }
|
||||||
|
// --------------------------------------------------------
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
const { entity_type, entity_id, remind_at } = req.body;
|
||||||
|
|
||||||
|
const errors = v.collectErrors([
|
||||||
|
v.oneOf(entity_type, VALID_ENTITY_TYPES, 'entity_type'),
|
||||||
|
v.id(entity_id, 'entity_id'),
|
||||||
|
v.datetime(remind_at, 'remind_at', true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!entity_type || !VALID_ENTITY_TYPES.includes(entity_type)) {
|
||||||
|
errors.push('entity_type muss "task" oder "event" sein.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length) {
|
||||||
|
return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityId = parseInt(entity_id, 10);
|
||||||
|
|
||||||
|
// Bestehende nicht-verworfene Erinnerungen für diese Entität löschen
|
||||||
|
db.get().prepare(`
|
||||||
|
DELETE FROM reminders
|
||||||
|
WHERE entity_type = ? AND entity_id = ? AND created_by = ?
|
||||||
|
`).run(entity_type, entityId, userId);
|
||||||
|
|
||||||
|
const result = db.get().prepare(`
|
||||||
|
INSERT INTO reminders (entity_type, entity_id, remind_at, created_by)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run(entity_type, entityId, remind_at, userId);
|
||||||
|
|
||||||
|
const row = db.get().prepare('SELECT * FROM reminders WHERE id = ?').get(result.lastInsertRowid);
|
||||||
|
res.status(201).json({ data: row });
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Fehler beim Erstellen der Erinnerung:', err.message);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler.', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// PATCH /api/v1/reminders/:id/dismiss
|
||||||
|
// Markiert eine Erinnerung als verworfen.
|
||||||
|
// Response: { data: { id } }
|
||||||
|
// --------------------------------------------------------
|
||||||
|
router.patch('/:id/dismiss', (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
const reminderId = parseInt(req.params.id, 10);
|
||||||
|
|
||||||
|
if (!reminderId) {
|
||||||
|
return res.status(400).json({ error: 'Ungültige Erinnerungs-ID.', code: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reminder = db.get().prepare(
|
||||||
|
'SELECT * FROM reminders WHERE id = ? AND created_by = ?'
|
||||||
|
).get(reminderId, userId);
|
||||||
|
|
||||||
|
if (!reminder) {
|
||||||
|
return res.status(404).json({ error: 'Erinnerung nicht gefunden.', code: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.get().prepare('UPDATE reminders SET dismissed = 1 WHERE id = ?').run(reminderId);
|
||||||
|
res.json({ data: { id: reminderId } });
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Fehler beim Verwerfen der Erinnerung:', err.message);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler.', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// DELETE /api/v1/reminders/:id
|
||||||
|
// Löscht eine Erinnerung dauerhaft.
|
||||||
|
// Response: 204 No Content
|
||||||
|
// --------------------------------------------------------
|
||||||
|
router.delete('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
const reminderId = parseInt(req.params.id, 10);
|
||||||
|
|
||||||
|
if (!reminderId) {
|
||||||
|
return res.status(400).json({ error: 'Ungültige Erinnerungs-ID.', code: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reminder = db.get().prepare(
|
||||||
|
'SELECT id FROM reminders WHERE id = ? AND created_by = ?'
|
||||||
|
).get(reminderId, userId);
|
||||||
|
|
||||||
|
if (!reminder) {
|
||||||
|
return res.status(404).json({ error: 'Erinnerung nicht gefunden.', code: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.get().prepare('DELETE FROM reminders WHERE id = ?').run(reminderId);
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Fehler beim Löschen der Erinnerung:', err.message);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler.', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// DELETE /api/v1/reminders?entity_type=task&entity_id=5
|
||||||
|
// Löscht alle Erinnerungen für eine Entität (z.B. bei Task-Löschung).
|
||||||
|
// Response: 204 No Content
|
||||||
|
// --------------------------------------------------------
|
||||||
|
router.delete('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
const entityType = req.query.entity_type;
|
||||||
|
const entityId = parseInt(req.query.entity_id, 10);
|
||||||
|
|
||||||
|
if (!VALID_ENTITY_TYPES.includes(entityType) || !entityId) {
|
||||||
|
return res.status(400).json({ error: 'entity_type und entity_id sind erforderlich.', code: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.get().prepare(`
|
||||||
|
DELETE FROM reminders
|
||||||
|
WHERE entity_type = ? AND entity_id = ? AND created_by = ?
|
||||||
|
`).run(entityType, entityId, userId);
|
||||||
|
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Fehler beim Löschen der Erinnerungen:', err.message);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler.', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
/**
|
||||||
|
* Modul: Reminders-Test
|
||||||
|
* Zweck: Validiert DB-Schema und Abfragen für Erinnerungen
|
||||||
|
* Ausführen: node --experimental-sqlite test-reminders.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
try { fn(); console.log(` ✓ ${name}`); passed++; }
|
||||||
|
catch (err) { console.error(` ✗ ${name}: ${err.message}`); failed++; }
|
||||||
|
}
|
||||||
|
function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion fehlgeschlagen'); }
|
||||||
|
|
||||||
|
// Hilfsfunktion: SQL-Block in Einzelstatements aufteilen und ausführen
|
||||||
|
function runSQL(database, sqlBlock) {
|
||||||
|
const statements = sqlBlock.split(';').map((s) => s.trim()).filter(Boolean);
|
||||||
|
for (const stmt of statements) {
|
||||||
|
database.prepare(stmt).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new DatabaseSync(':memory:');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON;');
|
||||||
|
|
||||||
|
// Minimales Schema aufbauen: users + calendar_events + reminders
|
||||||
|
runSQL(db, `
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
avatar_color TEXT NOT NULL DEFAULT '#007AFF',
|
||||||
|
role TEXT NOT NULL DEFAULT 'member',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
runSQL(db, `
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
runSQL(db, `
|
||||||
|
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
start_datetime TEXT NOT NULL,
|
||||||
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
external_source TEXT NOT NULL DEFAULT 'local'
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
runSQL(db, `
|
||||||
|
CREATE TABLE IF NOT EXISTS reminders (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
entity_type TEXT NOT NULL CHECK(entity_type IN ('task', 'event')),
|
||||||
|
entity_id INTEGER NOT NULL,
|
||||||
|
remind_at TEXT NOT NULL,
|
||||||
|
dismissed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
runSQL(db, `
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reminders_entity ON reminders(entity_type, entity_id)
|
||||||
|
`);
|
||||||
|
|
||||||
|
runSQL(db, `
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reminders_remind ON reminders(remind_at)
|
||||||
|
`);
|
||||||
|
|
||||||
|
runSQL(db, `
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reminders_user ON reminders(created_by)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Testdaten anlegen
|
||||||
|
const u1 = db.prepare(`INSERT INTO users (username, display_name, password_hash, avatar_color)
|
||||||
|
VALUES ('admin', 'Anna', 'x', '#007AFF')`).run();
|
||||||
|
const uid1 = u1.lastInsertRowid;
|
||||||
|
|
||||||
|
const task1 = db.prepare(`INSERT INTO tasks (title, created_by) VALUES ('Steuererklärung', ?)`).run(uid1);
|
||||||
|
const taskId = task1.lastInsertRowid;
|
||||||
|
|
||||||
|
const ev1 = db.prepare(`INSERT INTO calendar_events (title, start_datetime, created_by)
|
||||||
|
VALUES ('Zahnarzt', '2026-05-01T10:00', ?)`).run(uid1);
|
||||||
|
const evId = ev1.lastInsertRowid;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const future = new Date(Date.now() + 3_600_000).toISOString().slice(0, 16);
|
||||||
|
const past = new Date(Date.now() - 3_600_000).toISOString().slice(0, 16);
|
||||||
|
|
||||||
|
console.log('\n[Reminders-Test] Schema + CRUD + Pending-Abfragen\n');
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Erstellen
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
let reminderId1, reminderId2;
|
||||||
|
|
||||||
|
test('Erinnerung für Aufgabe erstellen', () => {
|
||||||
|
const r = db.prepare(`INSERT INTO reminders (entity_type, entity_id, remind_at, created_by)
|
||||||
|
VALUES ('task', ?, ?, ?)`).run(taskId, future, uid1);
|
||||||
|
reminderId1 = r.lastInsertRowid;
|
||||||
|
assert(reminderId1 > 0, 'Keine lastInsertRowid');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Erinnerung für Event erstellen', () => {
|
||||||
|
const r = db.prepare(`INSERT INTO reminders (entity_type, entity_id, remind_at, created_by)
|
||||||
|
VALUES ('event', ?, ?, ?)`).run(evId, past, uid1);
|
||||||
|
reminderId2 = r.lastInsertRowid;
|
||||||
|
assert(reminderId2 > 0, 'Keine lastInsertRowid');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Lesen
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
test('Erinnerung nach entity_type + entity_id laden', () => {
|
||||||
|
const row = db.prepare(`SELECT * FROM reminders
|
||||||
|
WHERE entity_type = 'task' AND entity_id = ? AND created_by = ?`).get(taskId, uid1);
|
||||||
|
assert(row !== null && row !== undefined, 'Keine Zeile zurückgegeben');
|
||||||
|
assert(row.entity_type === 'task', 'Falscher entity_type');
|
||||||
|
assert(row.dismissed === 0, 'dismissed sollte 0 sein');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Pending-Abfrage liefert nur fällige (remind_at <= now)', () => {
|
||||||
|
const rows = db.prepare(`SELECT * FROM reminders
|
||||||
|
WHERE created_by = ? AND dismissed = 0 AND remind_at <= ?`).all(uid1, now);
|
||||||
|
assert(rows.length === 1, `Erwartet 1, erhalten ${rows.length}`);
|
||||||
|
assert(rows[0].entity_type === 'event', 'Falscher entity_type in Pending-Abfrage');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zukunfts-Reminder erscheint nicht in Pending', () => {
|
||||||
|
const rows = db.prepare(`SELECT * FROM reminders
|
||||||
|
WHERE created_by = ? AND dismissed = 0 AND remind_at <= ?`).all(uid1, now);
|
||||||
|
const futureFound = rows.some((r) => r.id === reminderId1);
|
||||||
|
assert(!futureFound, 'Zukünftiger Reminder in Pending-Liste');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Verwerfen (dismiss)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
test('Erinnerung verwerfen', () => {
|
||||||
|
db.prepare(`UPDATE reminders SET dismissed = 1 WHERE id = ?`).run(reminderId2);
|
||||||
|
const row = db.prepare(`SELECT dismissed FROM reminders WHERE id = ?`).get(reminderId2);
|
||||||
|
assert(row.dismissed === 1, 'dismissed ist nicht 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Verworfene Erinnerung erscheint nicht in Pending', () => {
|
||||||
|
const rows = db.prepare(`SELECT * FROM reminders
|
||||||
|
WHERE created_by = ? AND dismissed = 0 AND remind_at <= ?`).all(uid1, now);
|
||||||
|
assert(rows.length === 0, `Erwartet 0 nach Dismiss, erhalten ${rows.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Ersetzen (Upsert: erst löschen, dann neu einfügen)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
test('Erinnerung ersetzen: alte löschen + neue einfügen', () => {
|
||||||
|
db.prepare(`DELETE FROM reminders WHERE entity_type = 'task' AND entity_id = ? AND created_by = ?`)
|
||||||
|
.run(taskId, uid1);
|
||||||
|
const newFuture = new Date(Date.now() + 7_200_000).toISOString().slice(0, 16);
|
||||||
|
db.prepare(`INSERT INTO reminders (entity_type, entity_id, remind_at, created_by)
|
||||||
|
VALUES ('task', ?, ?, ?)`).run(taskId, newFuture, uid1);
|
||||||
|
|
||||||
|
const rows = db.prepare(`SELECT * FROM reminders WHERE entity_type = 'task' AND entity_id = ?`)
|
||||||
|
.all(taskId);
|
||||||
|
assert(rows.length === 1, `Erwartet 1 Zeile nach Ersetzen, erhalten ${rows.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Löschen
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
test('Alle Erinnerungen einer Entität löschen', () => {
|
||||||
|
db.prepare(`DELETE FROM reminders WHERE entity_type = 'task' AND entity_id = ? AND created_by = ?`)
|
||||||
|
.run(taskId, uid1);
|
||||||
|
const rows = db.prepare(`SELECT * FROM reminders WHERE entity_type = 'task' AND entity_id = ?`)
|
||||||
|
.all(taskId);
|
||||||
|
assert(rows.length === 0, `Erwartet 0 nach Löschen, erhalten ${rows.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Nutzer-DELETE CASCADE: Erinnerungen werden mitgelöscht', () => {
|
||||||
|
const u2 = db.prepare(`INSERT INTO users (username, display_name, password_hash, avatar_color)
|
||||||
|
VALUES ('temp', 'Temp', 'x', '#FF0000')`).run();
|
||||||
|
const tempUid = u2.lastInsertRowid;
|
||||||
|
|
||||||
|
db.prepare(`INSERT INTO reminders (entity_type, entity_id, remind_at, created_by)
|
||||||
|
VALUES ('task', ?, ?, ?)`).run(taskId, future, tempUid);
|
||||||
|
|
||||||
|
db.prepare(`DELETE FROM users WHERE id = ?`).run(tempUid);
|
||||||
|
|
||||||
|
const rows = db.prepare(`SELECT * FROM reminders WHERE created_by = ?`).all(tempUid);
|
||||||
|
assert(rows.length === 0, 'Cascade-Delete hat nicht funktioniert');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Constraints
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
test('Ungültiger entity_type wird abgelehnt', () => {
|
||||||
|
let threw = false;
|
||||||
|
try {
|
||||||
|
db.prepare(`INSERT INTO reminders (entity_type, entity_id, remind_at, created_by)
|
||||||
|
VALUES ('invalid', 1, ?, ?)`).run(future, uid1);
|
||||||
|
} catch {
|
||||||
|
threw = true;
|
||||||
|
}
|
||||||
|
assert(threw, 'CHECK-Constraint hat entity_type nicht abgelehnt');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Ergebnis
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
console.log(`\n ${passed} bestanden, ${failed} fehlgeschlagen\n`);
|
||||||
|
if (failed > 0) process.exit(1);
|
||||||
Reference in New Issue
Block a user