diff --git a/public/js/main.js b/public/js/main.js
index e0e3d11..bd63aa8 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -330,10 +330,28 @@ function generatePdfDocument(settings, applications, month, year) {
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
const monthName = month ? monthNames[parseInt(month) - 1] : 'alle';
+
+ // The status history is sorted ascending, so its last entry is the most recent
+ // status change. The export keys date + status off that entry (falling back to
+ // the application's own date/status when there is no history).
+ const lastVerlauf = (app) => {
+ const v = Array.isArray(app.verlauf) ? app.verlauf : [];
+ return v.length ? v[v.length - 1] : null;
+ };
+ const lastStatusOf = (app) => {
+ const lv = lastVerlauf(app);
+ const s = (lv && lv.status) || app.status;
+ return (s && s.trim()) ? s.trim() : 'Ohne Status';
+ };
+ const effDateOf = (app) => {
+ const lv = lastVerlauf(app);
+ return new Date(lv ? lv.datum : app.datum);
+ };
+
// Determine the year shown in the title. When no year was picked in the form,
// derive it from the data so the title still reads e.g. "Juni 2026".
const yearsInData = [...new Set(
- applications.map((a) => new Date(a.datum).getFullYear()).filter((y) => !isNaN(y))
+ applications.map((a) => effDateOf(a).getFullYear()).filter((y) => !isNaN(y))
)].sort((a, b) => a - b);
const displayYear = year
|| (yearsInData.length === 1 ? String(yearsInData[0])
@@ -406,7 +424,7 @@ function generatePdfDocument(settings, applications, month, year) {
const groups = {};
applications.forEach((app) => {
- const key = (app.status && app.status.trim()) ? app.status.trim() : 'Ohne Status';
+ const key = lastStatusOf(app);
(groups[key] = groups[key] || []).push(app);
});
const orderedKeys = [
@@ -558,120 +576,66 @@ function generatePdfDocument(settings, applications, month, year) {
doc.setTextColor(0, 0, 0);
yPos += 12;
- // One block per application — no table, so the note (which documents the
- // full Verlauf) gets the entire page width and is shown completely.
- orderedKeys.forEach((statusKey) => {
- const group = groups[statusKey];
+ // ---- Compact application table (one row per application) ----
+ // Details such as Notizen and the full Status-Verlauf are intentionally left
+ // out; every row shows only the last status and the date of that status change.
+ sectionHeading('Bewerbungen');
- // Status group heading (dark bar), kept together with its first entry
- ensureSpace(40);
- doc.setFillColor(55, 65, 81);
- doc.rect(margin, yPos, contentWidth, 10, 'F');
- doc.setFont('helvetica', 'bold');
- doc.setFontSize(12);
- doc.setTextColor(255, 255, 255);
- doc.text(`${statusKey} (${group.length})`, margin + 3, yPos + 6.8);
- yPos += 14;
+ const tableRows = applications
+ .map((app) => ({ app, date: effDateOf(app), status: lastStatusOf(app) }))
+ .sort((a, b) => a.date - b.date);
+
+ if (tableRows.length === 0) {
+ doc.setFont('helvetica', 'italic');
+ doc.setFontSize(10.5);
+ doc.setTextColor(107, 114, 128);
+ doc.text('Keine Bewerbungen im gewählten Zeitraum.', margin, yPos + 2);
doc.setTextColor(0, 0, 0);
-
- group.forEach((app) => {
- const datum = new Date(app.datum).toLocaleDateString('de-DE');
- const firma = app.firma || '—';
- const stelle = app.stelle || '—';
- const art = app.art || '—';
- const notizen = (app.notizen || '').trim();
-
- // Keep the heading together with the start of its content
- ensureSpace(28);
-
- // Heading bar: Firma – Stelle (left), Datum (right)
- doc.setFillColor(225, 232, 240);
- doc.rect(margin, yPos, contentWidth, 9, 'F');
- doc.setFont('helvetica', 'bold');
- doc.setFontSize(11);
- doc.setTextColor(20, 20, 20);
- const heading = doc.splitTextToSize(`${firma} – ${stelle}`, contentWidth - 40)[0];
- doc.text(heading, margin + 2, yPos + 6);
- doc.text(datum, pageWidth - margin - 2, yPos + 6, { align: 'right' });
- yPos += 9;
-
- // Meta line: Art
- doc.setFont('helvetica', 'normal');
- doc.setFontSize(9);
- doc.setTextColor(90, 90, 90);
- doc.text(`Art: ${art}`, margin + 2, yPos + 5);
- yPos += 9;
-
- // Status-Verlauf timeline (chronological status changes with comments)
- const verlauf = Array.isArray(app.verlauf) ? app.verlauf : [];
- if (verlauf.length) {
- doc.setFont('helvetica', 'bold');
- doc.setFontSize(10);
- doc.setTextColor(20, 20, 20);
- ensureSpace(6);
- doc.text('Status-Verlauf:', margin + 2, yPos + 4);
- yPos += 6;
-
- doc.setFontSize(9);
- verlauf.forEach((v) => {
- const vDatum = new Date(v.datum).toLocaleDateString('de-DE');
- doc.setFont('helvetica', 'bold');
- doc.setTextColor(50, 50, 50);
- ensureSpace(5);
- doc.text(`${vDatum} — ${v.status || ''}`, margin + 5, yPos + 4);
- yPos += 5;
-
- const kommentar = (v.kommentar || '').trim();
- if (kommentar) {
- doc.setFont('helvetica', 'normal');
- doc.setTextColor(0, 0, 0);
- kommentar.split(/\r?\n/).forEach((paragraph) => {
- const lines = doc.splitTextToSize(paragraph.length ? paragraph : ' ', contentWidth - 12);
- lines.forEach((line) => {
- ensureSpace(4.5);
- doc.text(line, margin + 9, yPos + 3.5);
- yPos += 4.5;
- });
- });
- }
- });
- yPos += 3;
+ yPos += 10;
+ } else {
+ doc.autoTable({
+ startY: yPos,
+ margin: { left: margin, right: margin },
+ head: [['Datum', 'Firma', 'Stelle', 'Art', 'Status']],
+ body: tableRows.map(({ app, date, status }) => ([
+ isNaN(date) ? '—' : date.toLocaleDateString('de-DE'),
+ app.firma || '—',
+ app.stelle || '—',
+ app.art || '—',
+ status
+ ])),
+ styles: {
+ font: 'helvetica', fontSize: 9, cellPadding: 2.4,
+ textColor: [31, 41, 55], lineColor: [226, 232, 240],
+ lineWidth: 0.2, valign: 'middle', overflow: 'linebreak'
+ },
+ headStyles: {
+ fillColor: [55, 65, 81], textColor: [255, 255, 255],
+ fontStyle: 'bold', fontSize: 9.5, cellPadding: 2.6
+ },
+ alternateRowStyles: { fillColor: [248, 250, 252] },
+ columnStyles: {
+ 0: { cellWidth: 23 },
+ 1: { cellWidth: 41, fontStyle: 'bold' },
+ 2: { cellWidth: 'auto' },
+ 3: { cellWidth: 25 },
+ 4: { cellWidth: 39, halign: 'center', fontStyle: 'bold' }
+ },
+ // Tint the Status cell in its status colour so the column reads like a badge
+ didParseCell: (data) => {
+ if (data.section === 'body' && data.column.index === 4) {
+ const c = colorFor(String(data.cell.raw));
+ data.cell.styles.textColor = c;
+ data.cell.styles.fillColor = [
+ Math.round(c[0] * 0.13 + 255 * 0.87),
+ Math.round(c[1] * 0.13 + 255 * 0.87),
+ Math.round(c[2] * 0.13 + 255 * 0.87)
+ ];
+ }
}
-
- // Notizen — only rendered when the entry actually has notes
- if (notizen) {
- doc.setFont('helvetica', 'bold');
- doc.setFontSize(10);
- doc.setTextColor(20, 20, 20);
- doc.text('Notizen:', margin + 2, yPos + 4);
- yPos += 6;
-
- // Notizen body — full width, complete, with page breaks line by line
- doc.setFont('helvetica', 'normal');
- doc.setFontSize(10);
- doc.setTextColor(0, 0, 0);
- const lineHeight = 5;
- notizen.split(/\r?\n/).forEach((paragraph) => {
- const lines = doc.splitTextToSize(paragraph.length ? paragraph : ' ', contentWidth - 4);
- lines.forEach((line) => {
- ensureSpace(lineHeight);
- doc.text(line, margin + 2, yPos + 4);
- yPos += lineHeight;
- });
- });
- }
-
- // Separator before the next entry
- yPos += 4;
- ensureSpace(6);
- doc.setDrawColor(210, 210, 210);
- doc.line(margin, yPos, pageWidth - margin, yPos);
- yPos += 8;
});
-
- // Extra spacing after a status group
- yPos += 4;
- });
+ yPos = doc.lastAutoTable.finalY + 10;
+ }
// Footer with confirmation
ensureSpace(20);
diff --git a/server.js b/server.js
index a5ad17b..73824b4 100644
--- a/server.js
+++ b/server.js
@@ -917,12 +917,29 @@ initializeDatabase().then(() => {
// Get available months/years for filter
const availableMonths = await dbAll(`
- SELECT DISTINCT strftime("%Y-%m", datum) as yearmonth,
- strftime("%m", datum) as month,
+ SELECT DISTINCT strftime("%Y-%m", datum) as yearmonth,
+ strftime("%m", datum) as month,
strftime("%Y", datum) as year
FROM bewerbungen ORDER BY datum DESC
`);
-
+
+ // Months/years for the PDF export, keyed by the effective date (last status
+ // change) so a period like Juli 2026 is selectable even when the underlying
+ // application was created in an earlier month.
+ const exportMonths = await dbAll(`
+ SELECT DISTINCT strftime("%Y-%m", eff) as yearmonth,
+ strftime("%m", eff) as month,
+ strftime("%Y", eff) as year
+ FROM (
+ SELECT COALESCE(
+ (SELECT MAX(date(sv.datum)) FROM status_verlauf sv WHERE sv.bewerbung_id = b.id),
+ date(b.datum)
+ ) AS eff
+ FROM bewerbungen b
+ )
+ ORDER BY yearmonth DESC
+ `);
+
res.render('index', {
applications,
settings,
@@ -932,6 +949,7 @@ initializeDatabase().then(() => {
byStatus
},
availableMonths,
+ exportMonths,
currentFilter: { month, year },
kommendeTermine,
caldavTz: caldav.TZ,
@@ -1168,19 +1186,31 @@ initializeDatabase().then(() => {
try {
const { month, year } = req.query;
- let query = 'SELECT * FROM bewerbungen ORDER BY datum DESC';
+ // Effektives Datum einer Bewerbung = Datum ihrer letzten Statusänderung
+ // (fällt auf das Bewerbungsdatum zurück, wenn es keinen Verlauf gibt). Der
+ // Monatsexport listet eine Bewerbung im Monat ihres LETZTEN Status: Eine im
+ // Juni gesendete Bewerbung, die im Juli zum Vorstellungsgespräch wird,
+ // erscheint dadurch im Export für Juli.
+ const base = `
+ SELECT b.*, COALESCE(
+ (SELECT MAX(date(sv.datum)) FROM status_verlauf sv WHERE sv.bewerbung_id = b.id),
+ date(b.datum)
+ ) AS eff_datum
+ FROM bewerbungen b
+ `;
+ let query = `SELECT * FROM (${base}) ORDER BY eff_datum DESC`;
const params = [];
if (month && year) {
- query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC';
+ query = `SELECT * FROM (${base}) WHERE strftime("%m", eff_datum) = ? AND strftime("%Y", eff_datum) = ? ORDER BY eff_datum DESC`;
params.push(month.padStart(2, '0'), year);
} else if (month) {
// A month without a year must still restrict the export to that month —
// never fall through to exporting every application.
- query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? ORDER BY datum DESC';
+ query = `SELECT * FROM (${base}) WHERE strftime("%m", eff_datum) = ? ORDER BY eff_datum DESC`;
params.push(month.padStart(2, '0'));
} else if (year) {
- query = 'SELECT * FROM bewerbungen WHERE strftime("%Y", datum) = ? ORDER BY datum DESC';
+ query = `SELECT * FROM (${base}) WHERE strftime("%Y", eff_datum) = ? ORDER BY eff_datum DESC`;
params.push(year);
}
diff --git a/views/index.ejs b/views/index.ejs
index 8227d4b..f3bb610 100644
--- a/views/index.ejs
+++ b/views/index.ejs
@@ -732,7 +732,7 @@
name="month"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white">
- <% availableMonths.forEach(m => { %>
+ <% exportMonths.forEach(m => { %>
<% }); %>
@@ -747,7 +747,7 @@
name="year"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white">
- <% const pdfYears = [...new Set(availableMonths.map(m => m.year))].sort().reverse(); %>
+ <% const pdfYears = [...new Set(exportMonths.map(m => m.year))].sort().reverse(); %>
<% pdfYears.forEach(y => { %>
<% }); %>