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:
Ulas
2026-04-15 11:40:24 +02:00
parent 45008a4af6
commit e384ae1037
16 changed files with 1061 additions and 20 deletions
+1
View File
@@ -38,6 +38,7 @@
<link rel="stylesheet" href="/styles/layout.css" />
<link rel="stylesheet" href="/styles/glass.css" />
<link rel="stylesheet" href="/styles/login.css" />
<link rel="stylesheet" href="/styles/reminders.css" />
<!-- Theme: Vor CSS-Rendering anwenden (Flash-Prevention) -->
<script>
+21
View File
@@ -597,5 +597,26 @@
"unitWeeks": "Wochen",
"unitMonth": "Monat",
"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"
}
}
+21
View File
@@ -597,5 +597,26 @@
"unitWeeks": "weeks",
"unitMonth": "month",
"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"
}
}
+79 -7
View File
@@ -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
View File
@@ -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');
}
+257
View File
@@ -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 };
+4
View File
@@ -6,6 +6,7 @@
import { auth } from '/api.js';
import { initI18n, getLocale, t } from '/i18n.js';
import { init as initReminders, stop as stopReminders } from '/reminders.js';
// --------------------------------------------------------
// 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
if (typeof userOrPushState === 'object' && userOrPushState !== null) {
currentUser = userOrPushState;
initReminders();
} else {
pushState = userOrPushState;
}
@@ -159,6 +161,7 @@ async function navigate(path, userOrPushState = true, pushState = true) {
try {
const result = await auth.me();
currentUser = result.user;
initReminders();
} catch {
currentPath = null; // Reset damit navigate('/login') nicht geblockt wird
isNavigating = false;
@@ -507,6 +510,7 @@ window.addEventListener('popstate', (e) => {
// Session abgelaufen
window.addEventListener('auth:expired', () => {
currentUser = null;
stopReminders();
navigate('/login');
});
+96
View File
@@ -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
View File
@@ -12,7 +12,7 @@
* 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 ASSETS_CACHE = 'oikos-assets-v27';
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE];
@@ -31,6 +31,7 @@ const APP_SHELL = [
'/locales/ar.json',
'/locales/hi.json',
'/locales/pt.json',
'/reminders.js',
'/sw-register.js',
'/lucide.min.js',
'/styles/tokens.css',
@@ -39,6 +40,7 @@ const APP_SHELL = [
'/styles/layout.css',
'/styles/glass.css',
'/styles/login.css',
'/styles/reminders.css',
'/styles/dashboard.css',
'/styles/tasks.css',
'/styles/shopping.css',