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
+14
View File
@@ -178,6 +178,20 @@ const MIGRATIONS_SQL = {
);
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 };
+19
View File
@@ -369,6 +369,25 @@ const MIGRATIONS = [
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);
`,
},
];
/**
+2
View File
@@ -24,6 +24,7 @@ import contactsRouter from './routes/contacts.js';
import budgetRouter from './routes/budget.js';
import weatherRouter from './routes/weather.js';
import preferencesRouter from './routes/preferences.js';
import remindersRouter from './routes/reminders.js';
const log = createLogger('Server');
const logSync = createLogger('Sync');
@@ -165,6 +166,7 @@ app.use('/api/v1/contacts', contactsRouter);
app.use('/api/v1/budget', budgetRouter);
app.use('/api/v1/weather', weatherRouter);
app.use('/api/v1/preferences', preferencesRouter);
app.use('/api/v1/reminders', remindersRouter);
// --------------------------------------------------------
// Health-Check (für Docker)
+210
View File
@@ -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;