feat(series): choose open series or club members only for series points

Who scores is now an explicit per-series choice: every driver (default)
or club members only, with the club field shown only for the latter.
Series saved with a club keep members-only scoring. README updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-10-11 18:12:18 +02:00
parent efa082c2b5
commit ec200aef2b
11 changed files with 77 additions and 16 deletions

View File

@@ -542,7 +542,7 @@ const seriesCell = (table, row, event) => {
const text = result.absent ? "-" : String(result.points);
return result.counted ? text : `(${text})`;
};
const seriesSubtitle = (series) => [series.club, t("series.count_rule", { rule: series.countMode === "drop" ? t("series.count_drop_value", { count: series.countValue }) : t("series.count_best_value", { count: series.countValue }) })].filter(Boolean).join(" • ");
const seriesSubtitle = (series) => [series.scoring === "club" && series.club ? t("series.subtitle_members", { club: series.club }) : "", t("series.count_rule", { rule: series.countMode === "drop" ? t("series.count_drop_value", { count: series.countValue }) : t("series.count_best_value", { count: series.countValue }) })].filter(Boolean).join(" • ");
const printSeries = (series, tables) => openPrintWindow(series.name, `
<div class="print-header"><div><p class="print-kicker">${escapeHtml(t("series.standings"))}</p><h1>${escapeHtml(series.name)}</h1><p>${escapeHtml(seriesSubtitle(series))}</p></div>
<div class="print-meta"><p>${escapeHtml(new Date().toLocaleDateString())}</p></div></div>
@@ -710,9 +710,13 @@ const TRANSLATIONS = {
"series.settings": "Inställningar",
"series.name": "Namn",
"series.name_placeholder": "t.ex. Winter Championship 2026/2027",
"series.club": "Klubb (bara dess förare får poäng)",
"series.club_all": "tomt = alla får poäng",
"series.club_hint": "Förare från andra klubbar kör och syns i dagens resultat men får inga seriepoäng; klubbens förare rangordnas om utan dem. Klubben hämtas från förarregistret.",
"series.scoring": "Vem får seriepoäng",
"series.scoring_all": "Alla förare (öppen serie)",
"series.scoring_club": "Endast klubbmedlemmar",
"series.club": "Klubb",
"series.club_placeholder": "t.ex. Östersunds RC",
"series.club_hint": "Förare från andra klubbar kör och syns i dagens resultat men får inga seriepoäng; medlemmarna rangordnas om utan dem. Klubben hämtas från förarregistret.",
"series.subtitle_members": "endast medlemmar i {club}",
"series.classes": "Klasser (en tabell per klass)",
"series.no_classes": "Skapa klasser först under Klasser.",
"series.points_preset": "Poängtabell",
@@ -2432,9 +2436,13 @@ const TRANSLATIONS = {
"series.settings": "Settings",
"series.name": "Name",
"series.name_placeholder": "e.g. Winter Championship 2026/2027",
"series.club": "Club (only its drivers score)",
"series.club_all": "empty = everyone scores",
"series.club_hint": "Drivers from other clubs race and appear in the day result but get no series points; the club's drivers are re-ranked without them. The club comes from the driver register.",
"series.scoring": "Who scores series points",
"series.scoring_all": "Every driver (open series)",
"series.scoring_club": "Club members only",
"series.club": "Club",
"series.club_placeholder": "e.g. Östersunds RC",
"series.club_hint": "Drivers from other clubs race and appear in the day result but get no series points; members are re-ranked without them. The club comes from the driver register.",
"series.subtitle_members": "members of {club} only",
"series.classes": "Classes (one table per class)",
"series.no_classes": "Create classes first under Classes.",
"series.points_preset": "Points table",

View File

@@ -4,8 +4,8 @@
//
// Rules supported (see ROADMAP "Series / cup"):
// - points per place from a table, then a fixed step down to a minimum for later places;
// - only drivers from the series' club score (others still race and appear in the day
// result); club drivers are re-ranked without them;
// - who scores: every driver (open series), or only members of the series' club - others
// still race and appear in the day result, and members are re-ranked without them;
// - a driver with no registered lap in the event gets 0 points and no place;
// - count the best N results, or all completed events minus X (so a cancelled event
// reduces the count automatically);
@@ -37,6 +37,9 @@ export function normalizeSeries(series = {}, { uid } = {}) {
id: String(series.id || (uid ? uid("series") : "")),
name: String(series.name || "").trim(),
club: String(series.club || "").trim(),
// "all" = every driver scores; "club" = only drivers whose club matches. Series saved before
// this choice existed keep their behaviour: a club filled in meant members only.
scoring: series.scoring === "club" || (series.scoring === undefined && String(series.club || "").trim()) ? "club" : "all",
classIds: Array.isArray(series.classIds) ? [...new Set(series.classIds.map(String).filter(Boolean))] : [],
eventIds: Array.isArray(series.eventIds) ? [...new Set(series.eventIds.map(String).filter(Boolean))] : [],
pointsTable: table.length ? table : preset.table.slice(),
@@ -67,7 +70,7 @@ export function scoreSeriesEvent(series, dayResult, { getDriverClub = () => "" }
let place = 0;
(dayResult || []).forEach((row) => {
if (!row?.driverId || scored.has(row.driverId)) return;
if (series.club && !sameClub(getDriverClub(row.driverId), series.club)) return;
if (series.scoring === "club" && series.club && !sameClub(getDriverClub(row.driverId), series.club)) return;
if (!row.hasLap) {
scored.set(row.driverId, { place: null, points: 0, driverName: row.driverName });
return;

View File

@@ -16,7 +16,11 @@ function renderSeriesForm(series, { t, escapeHtml, state }) {
return `
<form id="seriesForm" class="form-grid cols-3 settings-grid">
<label><span>${t("series.name")}</span><input name="name" required value="${escapeHtml(series.name)}" placeholder="${escapeHtml(t("series.name_placeholder"))}" /></label>
<label><span>${t("series.club")}</span><input name="club" list="seriesClubOptions" value="${escapeHtml(series.club)}" placeholder="${escapeHtml(t("series.club_all"))}" />
<label><span>${t("series.scoring")}</span><select name="scoring" data-series-scoring>
<option value="all" ${series.scoring === "all" ? "selected" : ""}>${t("series.scoring_all")}</option>
<option value="club" ${series.scoring === "club" ? "selected" : ""}>${t("series.scoring_club")}</option>
</select></label>
<label data-series-club ${series.scoring === "club" ? "" : "hidden"}><span>${t("series.club")}</span><input name="club" list="seriesClubOptions" value="${escapeHtml(series.club)}" placeholder="${escapeHtml(t("series.club_placeholder"))}" />
<datalist id="seriesClubOptions">${clubs.map((club) => `<option value="${escapeHtml(club)}"></option>`).join("")}</datalist>
<small>${t("series.club_hint")}</small></label>
<fieldset class="series-classes"><legend>${t("series.classes")}</legend>
@@ -120,6 +124,9 @@ export function renderSeriesView(deps) {
if (!series) return;
const form = document.getElementById("seriesForm");
form?.querySelector("[data-series-scoring]")?.addEventListener("change", (event) => {
form.querySelector("[data-series-club]").hidden = event.target.value !== "club";
});
form?.querySelector("[data-series-preset]")?.addEventListener("change", (event) => {
const preset = SERIES_POINT_PRESETS[event.target.value];
if (!preset) return;
@@ -133,6 +140,7 @@ export function renderSeriesView(deps) {
Object.assign(series, normalizeSeries({
...series,
name: data.get("name"),
scoring: data.get("scoring"),
club: data.get("club"),
classIds: data.getAll("classIds"),
pointsTable: parsePointsTable(data.get("pointsTable")),