diff --git a/README.md b/README.md index 64ee901..e302480 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ JMK RB RaceController is an RC timing and race-control system with support for s - a preset-aware summary card that changes between endurance and normal race focus - status badges for `Setup`, `Format`, `Generation`, and `Live / results` - summary warnings when participants, sessions, teams, or lap-window settings are missing + - clickable summary warnings that jump to the relevant Manage section + - clickable step cards that act as quick navigation inside `Manage` + - compact step cards with live counters such as drivers, teams, sessions, and active/results state - separate `Race actions` for generation, reseeding and bump-up - reorganized in-app `Guide` with overview cards and two-column sections - explicit race participant selection @@ -145,7 +148,7 @@ JMK RB RaceController is an RC timing and race-control system with support for s - `Format` - `Generation` - `Live / results` -- `Setup` handles race participants and teams. +- `Setup` handles race participants and teams. The top step cards also act as quick navigation between the four blocks. - `Format` handles practice/qualifying, finals, validation, presets, and advanced settings. In Basic mode, endurance hides most qualifying/finals fields until Advanced is opened. - `Generation` is kept separate so actions like qualifying generation, reseeding, finals generation, and bump-up do not clutter the format form. - `Live / results` is where grid, standings, print, PDF, and finals matrix are used once the structure is ready. diff --git a/README.sv.md b/README.sv.md index 55d8a3d..45e2c6c 100644 --- a/README.sv.md +++ b/README.sv.md @@ -42,6 +42,9 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he - ett presetstyrt sammanfattningskort som växlar mellan endurance- och vanligt race-fokus - statusbrickor för `Setup`, `Format`, `Generering` och `Live / resultat` - sammanfattningsvarningar när deltagare, sessioner, lag eller varvfönster saknas + - klickbara sammanfattningsvarningar som hoppar till rätt del av Hantera + - klickbara stegkort som fungerar som snabbnavigering i `Hantera` + - kompaktare stegkort med levande räknare för t.ex. förare, lag, sessioner och aktiv/resultat-status - separata `Race actions` för generering, reseeding och bump-up - omgjord inbyggd `Guide` med översiktskort och tvåkolumnslayout - välj exakt vilka förare som är med i racet @@ -134,7 +137,7 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he - `Format` - `Generering` - `Live / resultat` -- `Setup` används för racedeltagare och lag. +- `Setup` används för racedeltagare och lag. Stegkorten högst upp fungerar också som snabbnavigering mellan de fyra blocken. - `Format` används för practice/kval, finaler, validering, presets och avancerade val. I Grundläge döljer endurance de flesta kval-/finalfält tills du öppnar Avancerat. - `Generering` ligger separat så knappar för kval, reseeding, finaler och bump-up inte blandas ihop med raceformatet. - `Live / resultat` är där grid, standings, print, PDF och finalmatris används när upplägget väl är klart. diff --git a/src/app.js b/src/app.js index 7630d3d..8ccf02c 100644 --- a/src/app.js +++ b/src/app.js @@ -213,7 +213,14 @@ const TRANSLATIONS = { "events.summary_warning_no_finals": "Inga finalsessioner finns ännu.", "events.summary_warning_no_teams": "Endurance/Team Race saknar lag. Lägg upp lag i Setup innan körning.", "events.summary_warning_invalid_lap_window": "Min/max varvtid ser fel ut. Max måste vara större än min.", + "events.detail_drivers": "förare", + "events.detail_teams": "lag", + "events.detail_sessions": "sessioner", + "events.detail_results": "resultat", + "events.detail_active": "aktiva", "guide.manage_steps_6": "6. Statusbrickorna visar om varje steg är klart, väntar eller behöver åtgärdas. Sammanfattningen till höger visar också varningar om deltagare, lag eller sessioner saknas.", + "guide.manage_steps_7": "7. Klicka på en varning i sammanfattningen för att hoppa direkt till rätt sektion i Hantera i stället för att leta manuellt.", + "guide.manage_steps_8": "8. Stegkorten högst upp fungerar också som snabbhopp. Klicka på Setup, Format, Generering eller Live / resultat för att gå direkt till rätt block.", "guide.race_wizard_title": "Create Race Wizard", "guide.race_wizard_1": "1. Börja i Race Setup och använd wizarden när du skapar ett nytt race, i stället för att bygga allt direkt i Hantera.", "guide.race_wizard_2": "2. Steg 1 sätter namn, datum, klass och preset. Presetet fyller rimliga standardvärden innan du finjusterar något.", @@ -940,7 +947,14 @@ const TRANSLATIONS = { "events.summary_warning_no_finals": "No finals exist yet.", "events.summary_warning_no_teams": "Endurance/Team Race has no teams yet. Add teams in Setup before running.", "events.summary_warning_invalid_lap_window": "The min/max lap window looks invalid. Max must be greater than min.", + "events.detail_drivers": "drivers", + "events.detail_teams": "teams", + "events.detail_sessions": "sessions", + "events.detail_results": "results", + "events.detail_active": "active", "guide.manage_steps_6": "6. The status badges show whether each step is complete, pending, or needs action. The summary on the right also warns when participants, teams, or sessions are missing.", + "guide.manage_steps_7": "7. Click a warning in the summary to jump straight to the matching Manage section instead of hunting for it manually.", + "guide.manage_steps_8": "8. The step cards at the top also work as quick jumps. Click Setup, Format, Generation or Live / results to move straight to the right block.", "guide.race_wizard_title": "Create Race Wizard", "guide.race_wizard_1": "1. Start in Race Setup and use the wizard when creating a new race instead of building everything directly in Manage.", "guide.race_wizard_2": "2. Step 1 sets name, date, class and preset. The preset fills sensible defaults before you fine-tune anything.", @@ -3779,7 +3793,7 @@ function renderEventManager(eventId) { } eventManageArea.innerHTML = ` -
+

${t("events.manage_title")}: ${escapeHtml(event.name)}

@@ -3926,27 +3940,31 @@ function renderEventManager(eventId) { ? `
-
-
${t("events.manage_step_setup")}${renderManageStatusBadge(manageStatuses?.setup || "pending")}
+
+
${t("events.manage_step_setup")}${renderManageStatusBadge(manageStatuses?.setup?.status || "pending")}
+
${escapeHtml(manageStatuses?.setup?.detail || "")}

${t("events.manage_step_setup_hint")}

-
-
${t("events.manage_step_format")}${renderManageStatusBadge(manageStatuses?.format || "pending")}
+
+
${t("events.manage_step_format")}${renderManageStatusBadge(manageStatuses?.format?.status || "pending")}
+
${escapeHtml(manageStatuses?.format?.detail || "")}

${t("events.manage_step_format_hint")}

-
-
${t("events.manage_step_generate")}${renderManageStatusBadge(manageStatuses?.generation || "pending")}
+
+
${t("events.manage_step_generate")}${renderManageStatusBadge(manageStatuses?.generation?.status || "pending")}
+
${escapeHtml(manageStatuses?.generation?.detail || "")}

${t("events.manage_step_generate_hint")}

-
-
${t("events.manage_step_live")}${renderManageStatusBadge(manageStatuses?.live || "pending")}
+
+
${t("events.manage_step_live")}${renderManageStatusBadge(manageStatuses?.live?.status || "pending")}
+
${escapeHtml(manageStatuses?.live?.detail || "")}

${t("events.manage_step_live_hint")}

-
+

${t("events.select_participants")}

${selectedParticipantCount} @@ -3974,7 +3992,7 @@ function renderEventManager(eventId) {
-
+

${t("events.teams")}

${raceTeams.length} @@ -4059,7 +4077,7 @@ function renderEventManager(eventId) {
-
+

${t("events.race_format")}

${t("events.race_format_intro")}

@@ -4295,7 +4313,7 @@ function renderEventManager(eventId) {
${t("events.summary_warnings_title")}
    - ${raceSummaryWarnings.map((warning) => `
  • ${escapeHtml(warning)}
  • `).join("")} + ${raceSummaryWarnings.map((warning) => `
  • `).join("")}
` : ""} @@ -4314,7 +4332,7 @@ function renderEventManager(eventId) {
-
+

${t("events.race_actions_title")}

${t("events.race_actions_hint")}

@@ -4328,14 +4346,14 @@ function renderEventManager(eventId) {
-
+

${t("events.grid_editor")}

${renderGridEditor(selectedGridSession)}
-
+

${t("events.practice_standings")}

${renderRaceStandingsTable(buildPracticeStandings(event), t("events.no_practice_results"))} @@ -4483,6 +4501,27 @@ function renderEventManager(eventId) { } `; + const bindManageJump = (node) => { + const triggerJump = () => { + const targetId = node.getAttribute("data-target") || ""; + const target = targetId ? document.getElementById(targetId) : null; + if (target) { + target.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }; + node.addEventListener("click", triggerJump); + node.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + triggerJump(); + } + }); + }; + + eventManageArea.querySelectorAll(".summary-warning-link, .manage-step-card-link").forEach((node) => { + bindManageJump(node); + }); + document.getElementById("eventBrandingForm")?.addEventListener("submit", (e) => { e.preventDefault(); const form = new FormData(e.currentTarget); @@ -5820,7 +5859,7 @@ function renderGuide() {
${renderGuidePanel("guide.race_title", ["guide.race_1", "guide.race_2", "guide.race_3", "guide.race_4", "guide.race_4a", "guide.race_5", "guide.race_6", "guide.race_7", "guide.race_8", "guide.race_9", "guide.race_10"])} - ${renderGuidePanel("guide.manage_steps_title", ["guide.manage_steps_1", "guide.manage_steps_2", "guide.manage_steps_3", "guide.manage_steps_4", "guide.manage_steps_5", "guide.manage_steps_6"])} + ${renderGuidePanel("guide.manage_steps_title", ["guide.manage_steps_1", "guide.manage_steps_2", "guide.manage_steps_3", "guide.manage_steps_4", "guide.manage_steps_5", "guide.manage_steps_6", "guide.manage_steps_7", "guide.manage_steps_8"])}
@@ -7781,24 +7820,24 @@ function getRaceSummaryWarnings(event, sessions, raceDrivers, raceTeams, selecte const hasFinals = sessions.some((session) => session.type === "final"); const hasAnySession = sessions.length > 0; if (participantCount <= 0) { - warnings.push(t("events.summary_warning_no_participants")); + warnings.push({ message: t("events.summary_warning_no_participants"), target: "manage-setup-participants" }); } if (!hasAnySession) { - warnings.push(t("events.summary_warning_no_sessions")); + warnings.push({ message: t("events.summary_warning_no_sessions"), target: "manage-session-plan" }); } if (!isEndurancePreset && !hasQualifying) { - warnings.push(t("events.summary_warning_no_qualifying")); + warnings.push({ message: t("events.summary_warning_no_qualifying"), target: "manage-generation" }); } if (!isEndurancePreset && !hasFinals) { - warnings.push(t("events.summary_warning_no_finals")); + warnings.push({ message: t("events.summary_warning_no_finals"), target: "manage-generation" }); } if (isEndurancePreset && raceTeams.length <= 0) { - warnings.push(t("events.summary_warning_no_teams")); + warnings.push({ message: t("events.summary_warning_no_teams"), target: "manage-setup-teams" }); } const minLapMs = Math.max(0, Number(cfg.minLapMs || 0) || 0); const maxLapMs = Math.max(0, Number(cfg.maxLapMs || 0) || 0); if (maxLapMs > 0 && maxLapMs <= minLapMs) { - warnings.push(t("events.summary_warning_invalid_lap_window")); + warnings.push({ message: t("events.summary_warning_invalid_lap_window"), target: "manage-format" }); } return warnings; } @@ -7807,14 +7846,19 @@ function getRaceManageStatuses(event, sessions, raceDrivers, raceTeams, selected const cfg = event.raceConfig || {}; const participantCount = cfg.participantsConfigured ? (cfg.driverIds || []).length : raceDrivers.length; const isEndurancePreset = selectedPreset?.id === "endurance"; - const hasQualifying = sessions.some((session) => session.type === "qualification"); - const hasFinals = sessions.some((session) => session.type === "final"); - const hasTeamRace = sessions.some((session) => session.type === "team_race"); - const hasStartedSession = sessions.some((session) => ["running", "finished", "stopped"].includes(String(session.status || "").toLowerCase()) || session.startedAt); + const practiceCount = sessions.filter((session) => ["practice", "free_practice", "open_practice"].includes(session.type)).length; + const qualCount = sessions.filter((session) => session.type === "qualification").length; + const finalCount = sessions.filter((session) => session.type === "final").length; + const teamCount = sessions.filter((session) => session.type === "team_race").length; + const hasQualifying = qualCount > 0; + const hasFinals = finalCount > 0; + const hasTeamRace = teamCount > 0; + const startedCount = sessions.filter((session) => ["running", "finished", "stopped"].includes(String(session.status || "").toLowerCase()) || session.startedAt).length; const practiceRows = buildPracticeStandings(event); const qualifyingRows = buildQualifyingStandings(event); const finalRows = buildFinalStandings(event); const lapWindowValid = Math.max(0, Number(cfg.maxLapMs || 0) || 0) > Math.max(0, Number(cfg.minLapMs || 0) || 0); + const resultSourceCount = [practiceRows, qualifyingRows, finalRows].filter((rows) => rows.length > 0).length; const setupStatus = participantCount > 0 && (!isEndurancePreset || raceTeams.length > 0) ? "complete" @@ -7827,17 +7871,27 @@ function getRaceManageStatuses(event, sessions, raceDrivers, raceTeams, selected const generationReady = isEndurancePreset ? hasTeamRace : hasQualifying || hasFinals; const generationStatus = generationReady ? "complete" : sessions.length > 0 ? "attention" : "pending"; - const liveStatus = hasStartedSession || practiceRows.length > 0 || qualifyingRows.length > 0 || finalRows.length > 0 - ? "complete" - : generationReady - ? "pending" - : "pending"; + const liveStatus = startedCount > 0 || resultSourceCount > 0 ? "complete" : generationReady ? "pending" : "pending"; return { - setup: setupStatus, - format: formatStatus, - generation: generationStatus, - live: liveStatus, + setup: { + status: setupStatus, + detail: `${participantCount} ${t("events.detail_drivers")} · ${raceTeams.length} ${t("events.detail_teams")}`, + }, + format: { + status: formatStatus, + detail: `${selectedPreset?.label || t("events.preset_custom")} · ${((cfg.minLapMs || 0) / 1000).toFixed(1)} / ${((cfg.maxLapMs || 0) / 1000).toFixed(1)}s`, + }, + generation: { + status: generationStatus, + detail: isEndurancePreset + ? `P ${practiceCount} · T ${teamCount} · ${sessions.length} ${t("events.detail_sessions")}` + : `Q ${qualCount} · F ${finalCount} · ${sessions.length} ${t("events.detail_sessions")}`, + }, + live: { + status: liveStatus, + detail: `${startedCount} ${t("events.detail_active")} · ${resultSourceCount} ${t("events.detail_results")}`, + }, }; } diff --git a/src/styles.css b/src/styles.css index 6a7f7aa..b233d20 100644 --- a/src/styles.css +++ b/src/styles.css @@ -178,7 +178,7 @@ body { .topbar-right { display: flex; align-items: center; - gap: 10px; + gap: 8px; } .lang-wrap { @@ -364,12 +364,12 @@ body { .wizard-step, .manage-step-card { - padding: 12px; + padding: 10px; border: 1px solid var(--line); border-radius: 12px; background: rgba(255, 255, 255, 0.025); display: grid; - gap: 6px; + gap: 5px; } .manage-step-head { @@ -408,14 +408,35 @@ body { .manage-step-card p { margin: 0; color: var(--muted); - line-height: 1.45; + line-height: 1.38; +} + +.manage-step-meta { + font-size: 0.78rem; + color: var(--muted); +} + +.manage-step-card-link { + cursor: pointer; + transition: border-color 0.2s ease, transform 0.2s ease, background 0.2s ease; +} + +.manage-step-card-link:hover { + border-color: rgba(225, 6, 0, 0.32); + transform: translateY(-1px); +} + +.manage-step-card-link:focus-visible { + outline: none; + border-color: rgba(225, 6, 0, 0.52); + box-shadow: 0 0 0 3px rgba(225, 6, 0, 0.12); } .status-chip { display: inline-flex; align-items: center; justify-content: center; - min-width: 82px; + min-width: 72px; padding: 4px 8px; border-radius: 999px; font-size: 0.72rem; @@ -471,13 +492,13 @@ body { .race-stage-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); - gap: 14px; + gap: 12px; } .race-setup-shell { display: grid; - grid-template-columns: minmax(0, 1.75fr) minmax(260px, 0.95fr); - gap: 14px; + grid-template-columns: minmax(0, 1.75fr) minmax(250px, 0.9fr); + gap: 12px; align-items: start; } @@ -591,6 +612,21 @@ body { gap: 6px; } +.summary-warning-link { + display: inline; + padding: 0; + border: 0; + background: transparent; + color: #ffd391; + text-align: left; + cursor: pointer; + font: inherit; +} + +.summary-warning-link:hover { + text-decoration: underline; +} + .panel-header-inline { padding: 0; border: 0;