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 = '';
+ %>
+
+
+
+
+
+
+
+
+
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 %>
+
+
+
+ <% }); %>
+
+
+
+
+
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 => { %>
+
+
+
+
+ <%= a.status %>
+
+ · <%= a.datum %>
+ <% if (a.kommentar) { %>· <%= a.kommentar %><% } %>
+
+
+ <% }); %>
+
+ <% } else { %>
Noch keine Statusänderungen protokolliert.
<% } %>
+
+
+ <% } %>
+
+
+