From 98a644524b2b43e7554581ce3756edfbd7e049b3 Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Tue, 14 Jul 2026 00:48:08 +0200 Subject: [PATCH] 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 --- server.js | 117 +++++++++++++ views/index.ejs | 47 ------ views/partials/header.ejs | 7 + views/statistik.ejs | 347 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 471 insertions(+), 47 deletions(-) create mode 100644 views/statistik.ejs diff --git a/server.js b/server.js index 7ab0791..b48a1e0 100644 --- a/server.js +++ b/server.js @@ -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 { diff --git a/views/index.ejs b/views/index.ejs index 604f1fc..d172918 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -130,53 +130,6 @@ <% } %> - -
-

Statistik

- -
- -
-
<%= statistics.total %>
-
Gesamtbewerbungen
-
- - -
-

Nach Bewerbungsart

-
- <% if (statistics.byArt.length > 0) { %> - <% statistics.byArt.forEach(item => { %> -
- <%= item.art %> - <%= item.count %> -
- <% }); %> - <% } else { %> -

Keine Daten verfügbar

- <% } %> -
-
- - -
-

Nach Status

-
- <% if (statistics.byStatus.length > 0) { %> - <% statistics.byStatus.forEach(item => { %> -
- <%= item.status %> - <%= item.count %> -
- <% }); %> - <% } else { %> -

Keine Daten verfügbar

- <% } %> -
-
-
-
- + + + <%- include('partials/header') %> + +
+ +
+
+

Statistik

+

Auswertung all deiner Bewerbungen — Verlauf, Erfolgsquote und Verteilungen.

+
+
+ + <%= total %> Bewerbungen +
+
+ + <% if (total === 0) { %> +
+
+ +

Noch keine Bewerbungen erfasst

+

Sobald du Bewerbungen anlegst, erscheint hier deine komplette Auswertung.

+
+
+ <% } else { %> + + +
+
+
+
+ +
+
<%= total %>
+
Bewerbungen gesamt
+
+ +
+
+
+ +
+
<%= kpis.gesendet %>
+
Versendet
+
+ +
+
+
+ +
+
<%= kpis.eingestellt %>
+
Einstellungen
+
<%= kpis.erfolgsquote %>% Erfolgsquote
+
+ +
+
+
+ +
+
<%= kpis.gespraech %>
+
Vorstellungsgespräche
+
+ +
+
+
+ +
+
<%= kpis.absagen %>
+
Absagen
+
<%= kpis.antwortquote %>% Antwortquote
+
+ +
+
+
+ +
+
<%= kpis.avgProMonat %>
+
Ø pro Monat
+
über <%= kpis.monateAktiv %> Monate
+
+
+ + +
+

Bewerbungen pro Monat

+

Wie viele Bewerbungen in jedem Monat erstellt wurden.

+ <% + const maxM = Math.max(1, ...byMonth.map(m => m.count)); + const VB = 1000, BH = 320; // viewBox dimensions + const padL = 28, padR = 10, padB = 26, padT = 10; + const innerW = VB - padL - padR; + const innerH = BH - padT - padB; + const slot = byMonth.length ? innerW / byMonth.length : innerW; + const bw = Math.min(slot * .62, 40); + const monthName = (ym) => { const [, mo] = ym.split('-'); return ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'][Number(mo)-1] || ''; }; + const yearOf = (ym) => ym.split('-')[0]; + let prevYear = ''; + %> +
+ + <% for (let gy = 1; gy <= 4; gy++) { const y = padT + innerH * (1 - gy/4); const val = Math.round(maxM * gy/4); %> + + <%= val %> + <% } %> + <% byMonth.forEach((m, i) => { const h = (m.count / maxM) * innerH; const x = padL + slot * i + (slot - bw)/2; const y = padT + innerH - h; const yr = yearOf(m.ym); const showYear = yr !== prevYear; prevYear = yr; %> + + <%= monthName(m.ym) %> + <% if (showYear) { %><%= yr %><% } %> + <%= m.ym %>: <%= m.count %> Bewerbung<%= m.count===1?'':'en' %> + <% }); %> + + + + + + + +
+
+ +
+ +
+

Verteilung nach Status

+

Aktueller Status jeder Bewerbung.

+ <% if (byStatus.length) { + const maxS = Math.max(...byStatus.map(s => s.count)); + const statusColor = (s) => ({ + 'Entwurf':'#94a3b8','Gesendet':'#3b82f6','Eingangsbestätigung':'#0ea5e9','In Bearbeitung':'#6366f1', + 'Interessiert':'#8b5cf6','Warten auf Rückmeldung':'#a3a3a3','Warten auf meine Antwort':'#a3a3a3', + 'Vorstellungsgespräch':'#10b981','Absage':'#f43f5e','Absage von meiner Seite':'#f97316', + 'Einstellung':'#059669','Keine Rückmeldung':'#cbd5e1' + }[s] || '#2563eb'); + %> + <% byStatus.forEach(s => { const pct = Math.round((s.count / maxS) * 100); %> +
+ <%= s.status %> +
+ <%= s.count %> +
+ <% }); %> + <% } else { %>
Keine Daten
<% } %> +
+ + +
+

Verteilung nach Bewerbungsart

+

Über welchen Kanal die Bewerbung lief.

+ <% if (byArt.length) { const maxA = Math.max(...byArt.map(a => a.count)); const artColor = ['#2563eb','#0ea5e9','#10b981','#8b5cf6','#f59e0b','#f43f5e','#6366f1','#ec4899','#14b8a6']; %> + <% byArt.forEach((a, i) => { const pct = Math.round((a.count / maxA) * 100); %> +
+ <%= a.art %> +
+ <%= a.count %> +
+ <% }); %> + <% } else { %>
Keine Daten
<% } %> +
+
+ +
+ +
+

Bewerbungs-Funnel

+

Wie viele Bewerbungen jede Stufe je erreicht haben.

+ <% const topF = Math.max(1, funnel[0].count); + const fColor = ['#2563eb','#3b82f6','#0ea5e9','#10b981','#059669']; + funnel.forEach((f, i) => { const pct = Math.round((f.count / topF) * 100); const conv = i === 0 ? '' : ' · ' + Math.round((f.count / Math.max(1, funnel[0].count) * 100)) + '%'; %> +
+
+ <%= f.label %> + <%= f.count %><%= conv %> +
+
+
<%= f.count %>
+
+
+ <% }); %> +
+ + +
+

Top Standorte

+

Wo du dich am häufigsten bewirbst.

+ <% if (byOrt.length) { const maxO = Math.max(...byOrt.map(o => o.count)); const ortColor = ['#6366f1','#0ea5e9','#10b981','#f59e0b','#f43f5e','#8b5cf6','#14b8a6','#ec4899','#3b82f6','#a3a3a3']; %> + <% byOrt.forEach((o, i) => { const pct = Math.round((o.count / maxO) * 100); %> +
+ <%= o.ort %> +
+ <%= o.count %> +
+ <% }); %> + <% } else { %>
Keine Standorte erfasst
<% } %> +
+
+ + +
+

Letzte Aktivität

+

Die jüngsten Statusänderungen deiner Bewerbungen.

+ <% if (aktivitaet.length) { + const statusColor = (s) => ({ + 'Entwurf':'#94a3b8','Gesendet':'#3b82f6','Eingangsbestätigung':'#0ea5e9','In Bearbeitung':'#6366f1', + 'Interessiert':'#8b5cf6','Warten auf Rückmeldung':'#a3a3a3','Warten auf meine Antwort':'#a3a3a3', + 'Vorstellungsgespräch':'#10b981','Absage':'#f43f5e','Absage von meiner Seite':'#f97316', + 'Einstellung':'#059669','Keine Rückmeldung':'#cbd5e1' + }[s] || '#2563eb'); + %> +
+ <% aktivitaet.forEach(a => { %> +
+
+ <% if (a.bewerbung_id) { %> + <%= a.firma || '(ohne Firma)' %> + · <%= a.stelle || '' %> + <% } else { %> + Gelöschte Bewerbung + <% } %> +
+
+ + <%= a.status %> + + · <%= a.datum %> + <% if (a.kommentar) { %>· <%= a.kommentar %><% } %> +
+
+ <% }); %> +
+ <% } else { %>
Noch keine Statusänderungen protokolliert.
<% } %> +
+ + <% } %> +
+ + \ No newline at end of file