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:
@@ -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