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:
51
server.js
51
server.js
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user