feat(series): add series/cup championships with points tables and standings

Group any number of races in freely chosen classes into a series with a
points table (ÖRC/100-80-70/F1/custom), best-N or drop-X counting, a
club filter that re-ranks members without guests, tie-breaks, rounds
created from a date list, and per-class standings with print and PDF.

Also fixes PDF export of wide tables: they are compacted and switched
to landscape instead of truncating names and dropping columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-10-10 19:49:23 +02:00
parent f65e526948
commit 9a9379aa83
18 changed files with 848 additions and 20 deletions

View File

@@ -816,14 +816,23 @@ function normalizePdfExportPayload(input = {}) {
return { title, subtitle, brandName, brandTagline, footer, theme, logoDataUrl, filename, sections };
}
// Wide tables (a series with 12 rounds has 16 columns) don't fit portrait A4 even when
// compacted - the page then turns landscape (126 characters per line instead of 86).
const PDF_PORTRAIT_CHARS = 86;
const PDF_LANDSCAPE_CHARS = 126;
function pdfNeedsLandscape(payload) {
return payload.sections.some((section) => section.headers.length && getCompactTableWidth(section.headers, section.rows) > PDF_PORTRAIT_CHARS);
}
function buildSimplePdf(payload) {
const pageWidth = 595;
const pageHeight = 842;
const landscape = pdfNeedsLandscape(payload);
const pageWidth = landscape ? 842 : 595;
const pageHeight = landscape ? 595 : 842;
const marginLeft = 42;
const marginTop = 56;
const marginBottom = 56;
const lineHeight = 15;
const maxChars = 86;
const maxChars = landscape ? PDF_LANDSCAPE_CHARS : PDF_PORTRAIT_CHARS;
const logo = parsePdfLogo(payload.logoDataUrl);
const headerOffset = logo ? logo.displayHeight + 18 : 0;
const lines = buildPdfLines(payload, maxChars);
@@ -1030,11 +1039,11 @@ function buildPdfLines(payload, maxChars) {
lines.push(payload.theme === "motorsport" ? `> ${section.title}` : section.title);
}
if (section.headers.length) {
const widths = getColumnWidths(section.headers, section.rows, maxChars);
lines.push(formatPdfRow(section.headers, widths));
lines.push(formatPdfRow(widths.map((width) => (payload.theme === "minimal" ? " ".repeat(Math.min(width, 1)) : "-".repeat(Math.min(width, 12)))), widths));
const { widths, separator } = getColumnWidths(section.headers, section.rows, maxChars);
lines.push(formatPdfRow(section.headers, widths, separator));
lines.push(formatPdfRow(widths.map((width) => (payload.theme === "minimal" ? " ".repeat(Math.min(width, 1)) : "-".repeat(Math.min(width, 12)))), widths, separator));
section.rows.forEach((row) => {
lines.push(formatPdfRow(row, widths));
lines.push(formatPdfRow(row, widths, separator));
});
} else {
section.rows.forEach((row) => {
@@ -1051,28 +1060,36 @@ function buildPdfLines(payload, maxChars) {
return lines;
}
const longestInColumn = (headers, rows, index) => Math.max(String(headers[index] || "").length, ...rows.map((row) => String(row[index] || "").length));
// Columns exactly as wide as their content (max 26), one space between them.
function getCompactTableWidth(headers, rows) {
return headers.reduce((sum, _header, index) => sum + Math.min(26, Math.max(1, longestInColumn(headers, rows, index))), 0) + Math.max(0, headers.length - 1);
}
// The usual layout (" | " between columns at least 4 wide) when it fits; otherwise compact
// columns with a single space; only then is the widest column shortened.
function getColumnWidths(headers, rows, maxChars) {
const widths = headers.map((header, index) => {
const columnValues = rows.map((row) => String(row[index] || ""));
const longest = Math.max(header.length, ...columnValues.map((value) => value.length), 4);
return Math.min(26, Math.max(4, longest));
});
let total = widths.reduce((sum, width) => sum + width, 0) + Math.max(0, (widths.length - 1) * 3);
const roomy = headers.map((_header, index) => Math.min(26, Math.max(4, longestInColumn(headers, rows, index))));
if (roomy.reduce((sum, width) => sum + width, 0) + Math.max(0, (roomy.length - 1) * 3) <= maxChars) {
return { widths: roomy, separator: " | " };
}
const widths = headers.map((_header, index) => Math.min(26, Math.max(1, longestInColumn(headers, rows, index))));
let total = widths.reduce((sum, width) => sum + width, 0) + Math.max(0, widths.length - 1);
while (total > maxChars) {
const widestIndex = widths.findIndex((width) => width === Math.max(...widths));
if (widths[widestIndex] <= 4) {
if (widths[widestIndex] <= 3) {
break;
}
widths[widestIndex] -= 1;
total -= 1;
}
return widths;
return { widths, separator: " " };
}
function formatPdfRow(values, widths) {
function formatPdfRow(values, widths, separator = " | ") {
return values
.map((value, index) => String(value || "").slice(0, widths[index]).padEnd(widths[index], " "))
.join(" | ");
.join(separator);
}
function wrapPdfText(text, maxChars) {