feat: Phase 3 Schritte 16–18 — Pinnwand, Kontakte, Budget-Tracker

Pinnwand (Notes):
- server/routes/notes.js: GET (sortiert: angeheftet zuerst), POST, PUT, PATCH /pin, DELETE
- public/pages/notes.js: Masonry-Grid, Markdown-Light-Renderer (fett/kursiv/Liste),
  Farb-Auswahl (8 Farben), helle/dunkle Textfarbe je nach Hintergrund, Pin-Toggle
- public/styles/notes.css: Masonry-Layout, Sticky-Note-Karten, Hover-Aktionen

Kontakte:
- server/routes/contacts.js: GET (Kategorie- + Volltextfilter), POST, PUT, DELETE, GET /meta
- public/pages/contacts.js: Kategorie-Filter-Chips, Echtzeit-Suche, Gruppenansicht,
  tel:/mailto:/maps-Links, CRUD-Modal
- public/styles/contacts.css: Toolbar mit Suche, Filter-Chips, Kontaktliste, Aktions-Buttons

Budget-Tracker:
- server/routes/budget.js: GET (Monatfilter), GET /summary (Einnahmen/Ausgaben/Saldo +
  Aufschlüsselung), GET /export (CSV mit BOM), POST, PUT, DELETE, GET /meta
- public/pages/budget.js: Monatsnavigation, 3 Zusammenfassungs-Karten, Kategorie-Balken
  (reines CSS, kein Canvas), Transaktionsliste, Einnahme/Ausgabe-Toggle, CSV-Download
- public/styles/budget.css: Summary-Cards, Balkendiagramm, Transaktionsliste, Modal

Tests: 34 neue Tests (10 Notes + 9 Contacts + 15 Budget), gesamt 146/146

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ulsklyc
2026-03-24 21:24:08 +01:00
parent 43e7ed55a9
commit 74b6e5f078
12 changed files with 2935 additions and 54 deletions
+278 -5
View File
@@ -1,13 +1,286 @@
/**
* Modul: Budget-Tracker (Budget)
* Zweck: REST-API-Routen für Einnahmen und Ausgaben
* Zweck: REST-API-Routen für Einnahmen/Ausgaben, Monatsübersicht, CSV-Export
* Abhängigkeiten: express, server/db.js, server/auth.js
*/
const express = require('express');
const router = express.Router();
'use strict';
// Platzhalter — wird in Phase 3 implementiert
router.get('/', (req, res) => res.json({ data: [] }));
const express = require('express');
const router = express.Router();
const db = require('../db');
const VALID_CATEGORIES = [
'Lebensmittel', 'Miete', 'Versicherung', 'Mobilität',
'Freizeit', 'Kleidung', 'Gesundheit', 'Bildung', 'Sonstiges',
];
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
// --------------------------------------------------------
// Statische Routen vor /:id
// --------------------------------------------------------
/**
* GET /api/v1/budget/summary
* Monatsübersicht: Einnahmen, Ausgaben, Saldo, Aufschlüsselung nach Kategorie.
* Query: ?month=YYYY-MM (default: aktueller Monat)
* Response: { data: { month, income, expenses, balance, byCategory: [] } }
*/
router.get('/summary', (req, res) => {
try {
const today = new Date().toISOString().slice(0, 7); // YYYY-MM
const month = req.query.month || today;
if (!/^\d{4}-\d{2}$/.test(month))
return res.status(400).json({ error: 'month muss YYYY-MM sein', code: 400 });
const from = `${month}-01`;
const to = `${month}-31`;
const totals = db.get().prepare(`
SELECT
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income,
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expenses,
SUM(amount) AS balance
FROM budget_entries
WHERE date BETWEEN ? AND ?
`).get(from, to);
const byCategory = db.get().prepare(`
SELECT category,
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income,
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expenses,
SUM(amount) AS total
FROM budget_entries
WHERE date BETWEEN ? AND ?
GROUP BY category
ORDER BY ABS(SUM(amount)) DESC
`).all(from, to);
res.json({
data: {
month,
income: totals.income || 0,
expenses: totals.expenses || 0,
balance: totals.balance || 0,
byCategory,
},
});
} catch (err) {
console.error('[budget/GET /summary]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* GET /api/v1/budget/export
* Monatseinträge als CSV-Download.
* Query: ?month=YYYY-MM
* Response: text/csv
*/
router.get('/export', (req, res) => {
try {
const today = new Date().toISOString().slice(0, 7);
const month = req.query.month || today;
if (!/^\d{4}-\d{2}$/.test(month))
return res.status(400).json({ error: 'month muss YYYY-MM sein', code: 400 });
const from = `${month}-01`;
const to = `${month}-31`;
const entries = db.get().prepare(`
SELECT b.*, u.display_name AS creator_name
FROM budget_entries b
LEFT JOIN users u ON u.id = b.created_by
WHERE b.date BETWEEN ? AND ?
ORDER BY b.date ASC
`).all(from, to);
const header = 'Datum,Titel,Betrag,Kategorie,Wiederkehrend,Erstellt von\n';
const rows = entries.map((e) =>
[
e.date,
`"${(e.title || '').replace(/"/g, '""')}"`,
e.amount.toFixed(2).replace('.', ','),
e.category,
e.is_recurring ? 'Ja' : 'Nein',
`"${(e.creator_name || '').replace(/"/g, '""')}"`,
].join(',')
).join('\n');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="budget-${month}.csv"`);
res.send('\uFEFF' + header + rows); // BOM für Excel
} catch (err) {
console.error('[budget/GET /export]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* GET /api/v1/budget/meta
* Kategorien-Liste für Dropdowns.
* Response: { data: { categories } }
*/
router.get('/meta', (req, res) => {
res.json({ data: { categories: VALID_CATEGORIES } });
});
// --------------------------------------------------------
// CRUD-Routen
// --------------------------------------------------------
/**
* GET /api/v1/budget
* Einträge eines Monats abrufen.
* Query: ?month=YYYY-MM&category=<cat>
* Response: { data: Entry[] }
*/
router.get('/', (req, res) => {
try {
const today = new Date().toISOString().slice(0, 7);
const month = req.query.month || today;
if (!/^\d{4}-\d{2}$/.test(month))
return res.status(400).json({ error: 'month muss YYYY-MM sein', code: 400 });
const from = `${month}-01`;
const to = `${month}-31`;
let sql = `
SELECT b.*, u.display_name AS creator_name
FROM budget_entries b
LEFT JOIN users u ON u.id = b.created_by
WHERE b.date BETWEEN ? AND ?
`;
const params = [from, to];
if (req.query.category && VALID_CATEGORIES.includes(req.query.category)) {
sql += ' AND b.category = ?';
params.push(req.query.category);
}
sql += ' ORDER BY b.date DESC, b.created_at DESC';
const entries = db.get().prepare(sql).all(...params);
res.json({ data: entries });
} catch (err) {
console.error('[budget/GET /]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* POST /api/v1/budget
* Neuen Eintrag anlegen.
* Body: { title, amount, category?, date, is_recurring?, recurrence_rule? }
* Response: { data: Entry }
*/
router.post('/', (req, res) => {
try {
const {
title, amount, category = 'Sonstiges',
date, is_recurring = 0, recurrence_rule = null,
} = req.body;
if (!title || !title.trim())
return res.status(400).json({ error: 'Titel ist erforderlich', code: 400 });
if (amount === undefined || amount === null || isNaN(Number(amount)))
return res.status(400).json({ error: 'Betrag (Zahl) ist erforderlich', code: 400 });
if (!date || !DATE_RE.test(date))
return res.status(400).json({ error: 'Gültiges Datum (YYYY-MM-DD) erforderlich', code: 400 });
if (!VALID_CATEGORIES.includes(category))
return res.status(400).json({ error: `Ungültige Kategorie: ${category}`, code: 400 });
const result = db.get().prepare(`
INSERT INTO budget_entries (title, amount, category, date, is_recurring, recurrence_rule, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
title.trim(), Number(amount), category, date,
is_recurring ? 1 : 0, recurrence_rule || null,
req.session.userId
);
const entry = db.get().prepare(`
SELECT b.*, u.display_name AS creator_name
FROM budget_entries b LEFT JOIN users u ON u.id = b.created_by
WHERE b.id = ?
`).get(result.lastInsertRowid);
res.status(201).json({ data: entry });
} catch (err) {
console.error('[budget/POST /]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* PUT /api/v1/budget/:id
* Eintrag bearbeiten.
* Body: alle Felder optional
* Response: { data: Entry }
*/
router.put('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const entry = db.get().prepare('SELECT * FROM budget_entries WHERE id = ?').get(id);
if (!entry) return res.status(404).json({ error: 'Eintrag nicht gefunden', code: 404 });
const { title, amount, category, date, is_recurring, recurrence_rule } = req.body;
if (amount !== undefined && isNaN(Number(amount)))
return res.status(400).json({ error: 'Betrag muss eine Zahl sein', code: 400 });
if (date !== undefined && !DATE_RE.test(date))
return res.status(400).json({ error: 'Ungültiges Datum', code: 400 });
if (category !== undefined && !VALID_CATEGORIES.includes(category))
return res.status(400).json({ error: `Ungültige Kategorie: ${category}`, code: 400 });
db.get().prepare(`
UPDATE budget_entries
SET title = COALESCE(?, title),
amount = COALESCE(?, amount),
category = COALESCE(?, category),
date = COALESCE(?, date),
is_recurring = COALESCE(?, is_recurring),
recurrence_rule = ?
WHERE id = ?
`).run(
title?.trim() ?? null,
amount !== undefined ? Number(amount) : null,
category ?? null,
date ?? null,
is_recurring !== undefined ? (is_recurring ? 1 : 0) : null,
recurrence_rule !== undefined ? (recurrence_rule || null) : entry.recurrence_rule,
id
);
const updated = db.get().prepare(`
SELECT b.*, u.display_name AS creator_name
FROM budget_entries b LEFT JOIN users u ON u.id = b.created_by WHERE b.id = ?
`).get(id);
res.json({ data: updated });
} catch (err) {
console.error('[budget/PUT /:id]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* DELETE /api/v1/budget/:id
* Eintrag löschen.
* Response: 204 No Content
*/
router.delete('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const result = db.get().prepare('DELETE FROM budget_entries WHERE id = ?').run(id);
if (result.changes === 0)
return res.status(404).json({ error: 'Eintrag nicht gefunden', code: 404 });
res.status(204).end();
} catch (err) {
console.error('[budget/DELETE /:id]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
module.exports = router;
+144 -4
View File
@@ -4,10 +4,150 @@
* Abhängigkeiten: express, server/db.js, server/auth.js
*/
const express = require('express');
const router = express.Router();
'use strict';
// Platzhalter — wird in Phase 3 implementiert
router.get('/', (req, res) => res.json({ data: [] }));
const express = require('express');
const router = express.Router();
const db = require('../db');
const VALID_CATEGORIES = ['Arzt', 'Schule/Kita', 'Behörde', 'Versicherung',
'Handwerker', 'Notfall', 'Sonstiges'];
/**
* GET /api/v1/contacts
* Alle Kontakte, optional nach Kategorie gefiltert und nach Name gesucht.
* Query: ?category=<cat>&q=<search>
* Response: { data: Contact[] }
*/
router.get('/', (req, res) => {
try {
let sql = 'SELECT * FROM contacts';
const params = [];
const where = [];
if (req.query.category && VALID_CATEGORIES.includes(req.query.category)) {
where.push('category = ?');
params.push(req.query.category);
}
if (req.query.q) {
where.push('(name LIKE ? OR phone LIKE ? OR email LIKE ?)');
const like = `%${req.query.q}%`;
params.push(like, like, like);
}
if (where.length) sql += ' WHERE ' + where.join(' AND ');
sql += ' ORDER BY category ASC, name ASC';
const contacts = db.get().prepare(sql).all(...params);
res.json({ data: contacts });
} catch (err) {
console.error('[contacts/GET /]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* POST /api/v1/contacts
* Neuen Kontakt anlegen.
* Body: { name, category?, phone?, email?, address?, notes? }
* Response: { data: Contact }
*/
router.post('/', (req, res) => {
try {
const {
name, category = 'Sonstiges',
phone = null, email = null, address = null, notes = null,
} = req.body;
if (!name || !name.trim())
return res.status(400).json({ error: 'Name ist erforderlich', code: 400 });
if (!VALID_CATEGORIES.includes(category))
return res.status(400).json({ error: `Ungültige Kategorie: ${category}`, code: 400 });
const result = db.get().prepare(`
INSERT INTO contacts (name, category, phone, email, address, notes)
VALUES (?, ?, ?, ?, ?, ?)
`).run(name.trim(), category, phone || null, email || null,
address || null, notes || null);
const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ data: contact });
} catch (err) {
console.error('[contacts/POST /]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* PUT /api/v1/contacts/:id
* Kontakt bearbeiten.
* Body: alle Felder optional
* Response: { data: Contact }
*/
router.put('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(id);
if (!contact) return res.status(404).json({ error: 'Kontakt nicht gefunden', code: 404 });
const { name, category, phone, email, address, notes } = req.body;
if (category !== undefined && !VALID_CATEGORIES.includes(category))
return res.status(400).json({ error: `Ungültige Kategorie: ${category}`, code: 400 });
db.get().prepare(`
UPDATE contacts
SET name = COALESCE(?, name),
category = COALESCE(?, category),
phone = ?,
email = ?,
address = ?,
notes = ?
WHERE id = ?
`).run(
name?.trim() ?? null,
category ?? null,
phone !== undefined ? (phone || null) : contact.phone,
email !== undefined ? (email || null) : contact.email,
address !== undefined ? (address || null) : contact.address,
notes !== undefined ? (notes || null) : contact.notes,
id
);
const updated = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(id);
res.json({ data: updated });
} catch (err) {
console.error('[contacts/PUT /:id]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* DELETE /api/v1/contacts/:id
* Kontakt löschen.
* Response: 204 No Content
*/
router.delete('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const result = db.get().prepare('DELETE FROM contacts WHERE id = ?').run(id);
if (result.changes === 0)
return res.status(404).json({ error: 'Kontakt nicht gefunden', code: 404 });
res.status(204).end();
} catch (err) {
console.error('[contacts/DELETE /:id]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* GET /api/v1/contacts/meta
* Kategorien-Liste für Dropdowns.
* Response: { data: { categories } }
*/
router.get('/meta', (req, res) => {
res.json({ data: { categories: VALID_CATEGORIES } });
});
module.exports = router;
+142 -5
View File
@@ -1,13 +1,150 @@
/**
* Modul: Pinnwand / Notizen (Notes)
* Zweck: REST-API-Routen für Notizen
* Zweck: REST-API-Routen für Notizen (CRUD, Pin-Toggle)
* Abhängigkeiten: express, server/db.js, server/auth.js
*/
const express = require('express');
const router = express.Router();
'use strict';
// Platzhalter — wird in Phase 3 implementiert
router.get('/', (req, res) => res.json({ data: [] }));
const express = require('express');
const router = express.Router();
const db = require('../db');
const COLOR_RE = /^#[0-9A-Fa-f]{6}$/;
/**
* GET /api/v1/notes
* Alle Notizen, angepinnte zuerst, dann nach updated_at DESC.
* Response: { data: Note[] }
*/
router.get('/', (req, res) => {
try {
const notes = db.get().prepare(`
SELECT n.*, u.display_name AS creator_name, u.avatar_color AS creator_color
FROM notes n
LEFT JOIN users u ON u.id = n.created_by
ORDER BY n.pinned DESC, n.updated_at DESC
`).all();
res.json({ data: notes });
} catch (err) {
console.error('[notes/GET /]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* POST /api/v1/notes
* Neue Notiz anlegen.
* Body: { content, title?, color?, pinned? }
* Response: { data: Note }
*/
router.post('/', (req, res) => {
try {
const { content, title = null, color = '#FFEB3B', pinned = 0 } = req.body;
if (!content || !content.trim())
return res.status(400).json({ error: 'Inhalt ist erforderlich', code: 400 });
if (!COLOR_RE.test(color))
return res.status(400).json({ error: 'Farbe muss #RRGGBB sein', code: 400 });
const result = db.get().prepare(`
INSERT INTO notes (content, title, color, pinned, created_by)
VALUES (?, ?, ?, ?, ?)
`).run(content.trim(), title?.trim() || null, color, pinned ? 1 : 0, req.session.userId);
const note = db.get().prepare(`
SELECT n.*, u.display_name AS creator_name, u.avatar_color AS creator_color
FROM notes n LEFT JOIN users u ON u.id = n.created_by
WHERE n.id = ?
`).get(result.lastInsertRowid);
res.status(201).json({ data: note });
} catch (err) {
console.error('[notes/POST /]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* PUT /api/v1/notes/:id
* Notiz bearbeiten.
* Body: { content?, title?, color?, pinned? }
* Response: { data: Note }
*/
router.put('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const note = db.get().prepare('SELECT * FROM notes WHERE id = ?').get(id);
if (!note) return res.status(404).json({ error: 'Notiz nicht gefunden', code: 404 });
const { content, title, color, pinned } = req.body;
if (color !== undefined && !COLOR_RE.test(color))
return res.status(400).json({ error: 'Farbe muss #RRGGBB sein', code: 400 });
db.get().prepare(`
UPDATE notes
SET content = COALESCE(?, content),
title = ?,
color = COALESCE(?, color),
pinned = COALESCE(?, pinned)
WHERE id = ?
`).run(
content?.trim() ?? null,
title !== undefined ? (title?.trim() || null) : note.title,
color ?? null,
pinned !== undefined ? (pinned ? 1 : 0) : null,
id
);
const updated = db.get().prepare(`
SELECT n.*, u.display_name AS creator_name, u.avatar_color AS creator_color
FROM notes n LEFT JOIN users u ON u.id = n.created_by WHERE n.id = ?
`).get(id);
res.json({ data: updated });
} catch (err) {
console.error('[notes/PUT /:id]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* PATCH /api/v1/notes/:id/pin
* Pin-Status toggeln.
* Response: { data: { id, pinned } }
*/
router.patch('/:id/pin', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const note = db.get().prepare('SELECT pinned FROM notes WHERE id = ?').get(id);
if (!note) return res.status(404).json({ error: 'Notiz nicht gefunden', code: 404 });
const newPinned = note.pinned ? 0 : 1;
db.get().prepare('UPDATE notes SET pinned = ? WHERE id = ?').run(newPinned, id);
res.json({ data: { id, pinned: newPinned } });
} catch (err) {
console.error('[notes/PATCH /:id/pin]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* DELETE /api/v1/notes/:id
* Notiz löschen.
* Response: 204 No Content
*/
router.delete('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const result = db.get().prepare('DELETE FROM notes WHERE id = ?').run(id);
if (result.changes === 0)
return res.status(404).json({ error: 'Notiz nicht gefunden', code: 404 });
res.status(204).end();
} catch (err) {
console.error('[notes/DELETE /:id]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
module.exports = router;