Statistik auf eigene Seite ausgelagert, umfangreich und modern

- Eigene Route /statistik mit umfassender Auswertung pro Benutzer
- KPI-Karten, Monats-Trend (SVG), Verteilung nach Status/Art/Ort,
  Bewerbungs-Funnel und letzte Aktivitaet als Timeline
- Statistik-Eintrag in der Top-Navigation (Desktop + Mobil)
- Kurzsstatistik vom Dashboard entfernt

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-14 00:48:08 +02:00
co-authored by Claude
parent 4fd843318f
commit 98a644524b
4 changed files with 471 additions and 47 deletions
+117
View File
@@ -1409,6 +1409,123 @@ initializeDatabase().then(async () => {
}
});
// Statistik — comprehensive, on its own page. The dashboard only carries a
// quick glance now; everything that needs room to breathe lives here.
app.get('/statistik', async (req, res) => {
try {
const U = uid();
const totalRow = await dbGet('SELECT COUNT(*) as count FROM bewerbungen WHERE user_id = ?', [U]);
const total = totalRow ? totalRow.count : 0;
const byArt = await dbAll(`
SELECT art, COUNT(*) as count FROM bewerbungen
WHERE user_id = ? AND art IS NOT NULL AND art != ''
GROUP BY art ORDER BY count DESC
`, [U]);
const statusRows = await dbAll(`
SELECT status, COUNT(*) as count FROM bewerbungen
WHERE user_id = ? AND status IS NOT NULL AND status != ''
GROUP BY status
`, [U]);
const statusMap = {};
statusRows.forEach((r) => { statusMap[r.status] = r.count; });
// Preserve the canonical STATUS_OPTIONS order so the bars read as a funnel
// top-to-bottom rather than a noisy rank-by-count.
const byStatus = STATUS_OPTIONS
.map((s) => ({ status: s, count: statusMap[s] || 0 }))
.filter((x) => x.count > 0);
const byOrt = await dbAll(`
SELECT ort, COUNT(*) as count FROM bewerbungen
WHERE user_id = ? AND ort IS NOT NULL AND ort != ''
GROUP BY ort ORDER BY count DESC LIMIT 10
`, [U]);
// Monthly trend (oldest first so the chart reads left-to-right).
const byMonth = await dbAll(`
SELECT strftime('%Y-%m', datum) as ym, COUNT(*) as count
FROM bewerbungen WHERE user_id = ? AND datum IS NOT NULL
GROUP BY ym ORDER BY ym ASC
`, [U]);
// How many applications ever reached each status — drives the funnel.
const verlaufStatus = await dbAll(`
SELECT status, COUNT(DISTINCT bewerbung_id) as count
FROM status_verlauf WHERE user_id = ? GROUP BY status
`, [U]);
const verlaufMap = {};
verlaufStatus.forEach((r) => { verlaufMap[r.status] = r.count; });
const ever = (s) => verlaufMap[s] || 0;
const cur = (s) => statusMap[s] || 0;
const entwuerfe = cur('Entwurf');
const gesendet = total - entwuerfe;
const gespraech = cur('Vorstellungsgespräch');
const eingestellt = cur('Einstellung');
const absagen = cur('Absage') + cur('Absage von meiner Seite');
// Any reply at all — everything past "Gesendet" except "Keine Rückmeldung".
const rueckStatus = [
'Eingangsbestätigung', 'In Bearbeitung', 'Interessiert',
'Warten auf Rückmeldung', 'Warten auf meine Antwort',
'Vorstellungsgespräch', 'Absage', 'Absage von meiner Seite', 'Einstellung'
];
const antworten = rueckStatus.reduce((a, s) => a + cur(s), 0);
const erfolgsquote = gesendet > 0 ? Math.round((eingestellt / gesendet) * 100) : 0;
const antwortquote = gesendet > 0 ? Math.round((antworten / gesendet) * 100) : 0;
const avgProMonat = byMonth.length > 0 ? Math.round((total / byMonth.length) * 10) / 10 : 0;
// Funnel: distinct applications that ever reached the stage. Einstellung
// implies a Gespräch happened, so count those toward the Gespräch stage too.
const funnel = [
{ label: 'Bewerbungen gesamt', count: total },
{ label: 'Versendet', count: ever('Gesendet') || gesendet },
{ label: 'Eingangsbestätigung', count: ever('Eingangsbestätigung') },
{ label: 'Vorstellungsgespräch', count: ever('Vorstellungsgespräch') + ever('Einstellung') },
{ label: 'Einstellung', count: ever('Einstellung') }
];
// Recent activity: latest status changes with the application they belong to.
const aktivitaet = await dbAll(`
SELECT sv.datum, sv.status, sv.kommentar, b.id as bewerbung_id,
b.firma, b.stelle
FROM status_verlauf sv
LEFT JOIN bewerbungen b ON b.id = sv.bewerbung_id
WHERE sv.user_id = ?
ORDER BY datetime(sv.created_at) DESC, sv.id DESC
LIMIT 12
`, [U]);
res.render('statistik', {
total,
byArt,
byStatus,
byOrt,
byMonth,
kpis: {
gesendet,
gespraech,
eingestellt,
absagen,
antworten,
erfolgsquote,
antwortquote,
avgProMonat,
monateAktiv: byMonth.length
},
funnel,
aktivitaet,
statusOptions: STATUS_OPTIONS
});
} catch (error) {
console.error('Error loading statistik:', error);
res.status(500).send('Serverfehler');
}
});
// Get single application
app.get('/api/bewerbungen/:id', async (req, res) => {
try {