diff --git a/README.md b/README.md index a56eb3d..0afdb1e 100644 --- a/README.md +++ b/README.md @@ -243,7 +243,8 @@ The personal profile can also store `Club` and `Country`. These values are inher - automatic stint boundaries at the configured `Max lap time`, with a 2-minute fallback for legacy/unconfigured data - best 3 consecutive laps plus best 5-minute result and its average pace - the best 5-minute result counts the final lap when it started before `5:00` (`300 s`), even when the result finishes after the cutoff -- daily history for the latest practice days and recent personal-best improvement +- mobile profile pace chart, recent class-specific stint summaries, and daily history for the latest practice days +- per-profile publishing controls for development, achievements, performance/stints, goals/rewards, and history; disabled detail sections are filtered by the public API - personal development trend comparing the latest 30 valid laps with the preceding 30, always isolated per class - automatic achievements for first practice, lap totals, practice days, controlled streaks, and completed five-minute stints - optional personal stable-pace voice feedback after five controlled laps in one practice stint, using the configured driver spoken name and a per-driver cooldown diff --git a/README.sv.md b/README.sv.md index 0350e36..0cb41a8 100644 --- a/README.sv.md +++ b/README.sv.md @@ -232,7 +232,8 @@ Förarprofilen kan även lagra `Klubb` och `Land`. Värdena ärvs när ytterliga - automatisk ny stint vid inställd `Max varvtid`, med 2 minuter som reservregel för äldre eller okonfigurerad data - bästa 3 sammanhängande varv samt bästa femminutersresultat med dess avg pace - femminutersresultatet räknar slutvarvet om det startade före `5:00` (`300 s`), även när målgången sker efter tidsgränsen -- daglig historik för de senaste träningsdagarna och förbättring mot tidigare personbästa +- mobilprofil med tempograf, senaste klasspecifika stintar och daglig historik för de senaste träningsdagarna +- publiceringsval per profil för utveckling, utmärkelser, prestation/stints, mål/belöningar och historik; avstängda detaljsektioner filtreras av det publika API:t - personlig utvecklingstrend som jämför de senaste 30 giltiga varven med föregående 30, alltid separat per klass - automatiska achievements för första passet, varvmängd, träningsdagar, kontrollerade varvserier och fullföljd femminutersstint - valbar personlig röstfeedback efter fem kontrollerade varv i samma practice-stint, med förarens uppläsningsnamn och cooldown per förare diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index f840875..20b7475 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -194,6 +194,8 @@ Owns: - profile resolution across several race-specific driver records - strict per-class lap filtering and aggregate statistics - sanitized branding plus class-specific goals and recommendation response +- server-side visibility filtering for progress, achievements, performance/stints, goals, and history +- bounded class-specific pace history and recent stint summaries for the mobile profile - clock-skew-safe personal timer start/window normalization - bounded current-session lap, best-lap, and average calculations @@ -611,6 +613,7 @@ Owns: Mutations: - replaces or appends entries in `state.training.goals` - creates, rotates, enables, or disables entries in `state.training.shares` +- persists per-share visibility for development, achievements, performance/stints, goals, and history - creates and removes entries in `state.training.markers` - replaces or clears `state.training.clubGoal` - always calls injected `saveState()` before rerender @@ -637,7 +640,8 @@ Type: Owns: - fetching only `/public-training/api/:shareToken` -- rendering one person's sanitized statistics, goals, rewards, and daily history with a class selector +- rendering one person's sanitized statistics, pace chart, recent stints, goals, rewards, and daily history with a class selector +- hiding optional sections according to the server-provided share visibility map - switching between class-specific statistics without combining lap performance across classes - local 20-minute practice timer with 10-minute push/control phases - token-scoped start-age and bounded-window polling for aggregate lap count, best lap, and average during the personal timer; using an age avoids mobile/server clock-skew errors and the bounded window freezes results after the timer finishes @@ -656,7 +660,7 @@ Type: Owns: - phone-first layout independent from the admin sidebar/theme system -- profile, level, timer, goals, and daily-history presentation +- profile, level, timer, pace chart, collapsible stint details, goals, and daily-history presentation ## 3. Direct Import Dependencies @@ -1859,7 +1863,7 @@ This bucket is the most dynamic part of state and is mutated heavily during live { goals: TrainingGoal[], ledger: TrainingLap[], - shares: TrainingShare[], + shares: TrainingShare[], // includes per-link public section visibility markers: TrainingMarker[], clubGoal: TrainingClubGoal | null } @@ -2312,7 +2316,8 @@ Sanitized response fields: - selected person's display name plus linked class names and brands - one `classProfiles` entry per linked class; no cross-class performance aggregate is emitted - no club rank or other driver names; the public profile focuses on personal development -- class-specific totals, best/average time, consistency, controlled streak, stints, best-three, five-minute result, and daily summaries +- class-specific totals plus optional pace history, achievements, stint summaries, best-three, five-minute result, goals, and daily summaries +- optional detail sections are omitted server-side according to the share visibility map; core lap/day/best/consistency totals remain visible - optional class-specific `currentSession` aggregates when a valid start age (or backward-compatible recent `since` timestamp) is supplied - class-specific next automatic milestone, recommendation level, target lap, goals, and reward progress - public club branding and safe PNG/JPEG/WebP logo data diff --git a/server/public_training_profile.js b/server/public_training_profile.js index 31fcf35..03cea6c 100644 --- a/server/public_training_profile.js +++ b/server/public_training_profile.js @@ -29,9 +29,43 @@ function getPublicDriverProfile(drivers, reference) { }; } +const PUBLIC_TRAINING_SECTION_KEYS = ["progress", "achievements", "performance", "goals", "history"]; + +function normalizePublicTrainingVisibility(value) { + const source = value && typeof value === "object" ? value : {}; + return Object.fromEntries(PUBLIC_TRAINING_SECTION_KEYS.map((key) => [key, source[key] !== false])); +} + +function filterPublicTrainingStats(stats, visibility) { + const filtered = { + totalLaps: stats.totalLaps, totalSessions: stats.totalSessions, trainingDays: stats.trainingDays, + bestLapMs: stats.bestLapMs, averageLapMs: stats.averageLapMs, recentLapCount: stats.recentLapCount, + consistencySpreadPct: stats.consistencySpreadPct, + }; + if (visibility.progress) Object.assign(filtered, { + recentAverageMs: stats.recentAverageMs, previousRecentAverageMs: stats.previousRecentAverageMs, + averageTrendMs: stats.averageTrendMs, previousConsistencySpreadPct: stats.previousConsistencySpreadPct, + consistencyTrendPct: stats.consistencyTrendPct, trendSampleReady: stats.trendSampleReady, + controlledLapStreak: stats.controlledLapStreak, todayControlledLapStreak: stats.todayControlledLapStreak, + todayStrongFinishStints: stats.todayStrongFinishStints, todayCleanFiveMinuteStints: stats.todayCleanFiveMinuteStints, + todayCompletedFiveMinuteStints: stats.todayCompletedFiveMinuteStints, paceHistory: stats.dailySummaries.slice(0, 12), + }); + if (visibility.achievements) filtered.achievements = stats.achievements; + if (visibility.performance) Object.assign(filtered, { + controlZoneMinMs: stats.controlZoneMinMs, controlZoneMaxMs: stats.controlZoneMaxMs, + outlierLaps: stats.outlierLaps, strongFinishStints: stats.strongFinishStints, + cleanFiveMinuteStints: stats.cleanFiveMinuteStints, stintCount: stats.stintCount, + stintSummaries: stats.stintSummaries, bestThreeConsecutive: stats.bestThreeConsecutive, + bestFiveMinuteResult: stats.bestFiveMinuteResult, + }); + if (visibility.history) filtered.dailySummaries = stats.dailySummaries; + return filtered; +} + function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp = 0, windowMs = 0) { const share = findPublicTrainingShare(state, shareToken); if (!share) return null; + const visibility = normalizePublicTrainingVisibility(share.visibility); const profileReference = String(share.driverId || ""); const drivers = Array.isArray(state?.drivers) ? state.drivers : []; const driverProfile = getPublicDriverProfile(drivers, profileReference); @@ -66,11 +100,11 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp classId, className: String(classes.find((item) => String(item?.id || "") === classId)?.name || "").trim(), brand: brands.join(" / "), - stats, - channelStats, + stats: filterPublicTrainingStats(stats, visibility), + channelStats: visibility.performance ? channelStats : {}, nextMilestone: getPublicTrainingMilestone(stats.totalLaps), recommendation: getPublicTrainingRecommendation(stats), - goals: classGoals, + goals: visibility.goals ? classGoals : [], currentSession: sinceTimestamp ? buildPublicTrainingWindowStats(classLaps.filter((lap) => lap.timestamp >= sinceTimestamp && (!windowMs || lap.timestamp <= sinceTimestamp + windowMs))) : null, @@ -78,14 +112,15 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp }); const activeClass = classProfiles[0] || { classId: "", className: "", brand: "", - stats: buildPublicTrainingStats([]), nextMilestone: getPublicTrainingMilestone(0), - channelStats: buildPublicTimingChannelStats({}, new Set()), + stats: filterPublicTrainingStats(buildPublicTrainingStats([]), visibility), nextMilestone: getPublicTrainingMilestone(0), + channelStats: visibility.performance ? buildPublicTimingChannelStats({}, new Set()) : {}, recommendation: getPublicTrainingRecommendation({ totalLaps: 0 }), goals: [], currentSession: null, }; const settings = state?.settings && typeof state.settings === "object" ? state.settings : {}; return { updatedAt: String(updatedAt || ""), + visibility, profile: { name: String(driver.name || "Driver").trim(), brand: activeClass.brand, @@ -263,6 +298,17 @@ function buildPublicTrainingStats(laps) { }))); const bestThreeConsecutive = getPublicBestConsecutiveLaps(stints, 3); const bestFiveMinuteResult = getPublicBestDurationResult(stints, 5 * 60 * 1000); + const stintSummaries = stints.map((stint, index) => { + const paceLaps = stint.laps.filter((lap) => !lap.excludedFromPace); + const values = paceLaps.map((lap) => lap.lapMs); + const startedAt = Math.max(0, Number(stint.laps[0]?.timestamp || 0) - Number(stint.laps[0]?.lapMs || 0)); + const endedAt = Number(stint.laps.at(-1)?.timestamp || startedAt); + return { + number: index + 1, startedAt, lapCount: stint.laps.length, + bestLapMs: values.length ? Math.min(...values) : 0, averageLapMs: average(values), + durationMs: Math.max(0, endedAt - startedAt), + }; + }).sort((left, right) => right.startedAt - left.startedAt).slice(0, 8); const stats = { totalLaps: laps.length, @@ -290,6 +336,7 @@ function buildPublicTrainingStats(laps) { todayCompletedFiveMinuteStints: todayStintSummary.completedFiveMinuteStints, dailySummaries, stintCount: stints.length, + stintSummaries, bestThreeConsecutive, bestFiveMinuteResult, }; diff --git a/src/app.js b/src/app.js index fac7a1a..301bcb8 100644 --- a/src/app.js +++ b/src/app.js @@ -570,6 +570,13 @@ const TRANSLATIONS = { "training.public_profile_off": "Avstängd", "training.public_profile_help": "Skapa en skrivskyddad mobilvy med den här förarens träningsstatistik, mål, belöningar och valbara träningstimer.", "training.public_profile_private": "Länken innehåller en unik hemlig nyckel. Den visar inte transponder-ID, övriga förare eller adminfunktioner.", + "training.public_sections": "Synligt i mobilprofilen", + "training.public_sections_hint": "Valen sparas direkt. Avstängda sektioner skickas inte till den publika profilen.", + "training.public_section_progress": "Utveckling och graf", + "training.public_section_achievements": "Utmärkelser", + "training.public_section_performance": "Stints och prestation", + "training.public_section_goals": "Mål och belöningar", + "training.public_section_history": "Träningshistorik", "training.public_profile_link": "Personlig delningslänk", "training.public_base_url": "Publik adress eller FQDN", "training.public_base_url_hint": "Exempel: https://jmkrb.myddns.rocks. Lämna tomt för adressen där RaceCore öppnades.", @@ -1624,6 +1631,7 @@ const TRANSLATIONS = { "guide.training_23": "23. Skapa ett klubbmål från Träningsöversikten. Målet börjar på noll när det sparas och summerar därefter alla giltiga registrerade träningsvarv. När målet nås kan det markeras som firat och ersättas med ett nytt.", "guide.training_24": "24. Ljudhändelsen training stable pace ger valbar personlig röstfeedback efter fem kontrollerade varv i samma practice-stint. Den använder förarens uppläsningsnamn, har två minuters cooldown och aktiveras separat under Raceljud och utrop.", "guide.training_25": "25. Kontrollzonen går från personbästa till 8 procent långsammare. Flytserier bryts vid avvikande varv, ny stint eller ny session. Stark avslutning, rena femminuterspass och förbättrade mellantider ger positiv feedback men påverkar aldrig race-resultat.", + "guide.training_26": "26. Under Mål och mobilprofil väljer du vilka publika sektioner som visas: utvecklingsgraf, utmärkelser, stints/prestation, mål/belöningar och historik. Valen sparas direkt och avstängd detaljdata lämnas inte ut av profilens API.", "overlay.title": "Overlay", "overlay.subtitle": "Extern leaderboard-skärm", "overlay.no_active": "Ingen aktiv session vald.", @@ -1935,6 +1943,13 @@ const TRANSLATIONS = { "training.public_profile_off": "Disabled", "training.public_profile_help": "Create a read-only mobile view with this driver's practice statistics, goals, rewards and configurable practice timer.", "training.public_profile_private": "The link contains a unique secret key. It does not show transponder IDs, other drivers or admin controls.", + "training.public_sections": "Visible in mobile profile", + "training.public_sections_hint": "Changes are saved immediately. Disabled sections are not sent to the public profile.", + "training.public_section_progress": "Development and chart", + "training.public_section_achievements": "Achievements", + "training.public_section_performance": "Stints and performance", + "training.public_section_goals": "Goals and rewards", + "training.public_section_history": "Practice history", "training.public_profile_link": "Personal share link", "training.public_base_url": "Public address or FQDN", "training.public_base_url_hint": "Example: https://training.example.org. Leave empty to use the address where RaceCore was opened.", @@ -2989,6 +3004,7 @@ const TRANSLATIONS = { "guide.training_23": "23. Create a club goal from Practice overview. The target starts at zero when saved and then aggregates every valid registered practice lap. Once reached, it can be marked as celebrated and replaced with a new goal.", "guide.training_24": "24. The training stable pace audio event provides optional personal voice feedback after five controlled laps in one practice stint. It uses the driver spoken name, has a two-minute cooldown, and can be enabled separately under Race sounds and announcements.", "guide.training_25": "25. The control zone runs from the personal best to 8 percent slower. Flow streaks reset on an outlier lap, a new stint, or a new session. Strong finishes, clean five-minute runs, and improved split times provide positive feedback but never affect race results.", + "guide.training_26": "26. Under Goals and mobile profile, choose which public sections are visible: development chart, achievements, stints/performance, goals/rewards, and history. Changes save immediately and disabled detail data is not exposed by the profile API.", "overlay.title": "Overlay", "overlay.subtitle": "External leaderboard screen", "overlay.no_active": "No active session selected.", diff --git a/src/misc_views.js b/src/misc_views.js index 5630248..45db418 100644 --- a/src/misc_views.js +++ b/src/misc_views.js @@ -69,7 +69,7 @@ export function renderGuideView({ dom, t }) {
${renderGuidePanel("guide.dashboard_title", ["guide.dashboard_1", "guide.dashboard_2", "guide.dashboard_3", "guide.dashboard_4"], "", { t })} - ${renderGuidePanel("guide.training_title", ["guide.training_1", "guide.training_2", "guide.training_3", "guide.training_4", "guide.training_5", "guide.training_6", "guide.training_7", "guide.training_8", "guide.training_9", "guide.training_10", "guide.training_11", "guide.training_12", "guide.training_13", "guide.training_14", "guide.training_15", "guide.training_16", "guide.training_17", "guide.training_18", "guide.training_19", "guide.training_20", "guide.training_21", "guide.training_22", "guide.training_23", "guide.training_24", "guide.training_25"], "", { t })} + ${renderGuidePanel("guide.training_title", ["guide.training_1", "guide.training_2", "guide.training_3", "guide.training_4", "guide.training_5", "guide.training_6", "guide.training_7", "guide.training_8", "guide.training_9", "guide.training_10", "guide.training_11", "guide.training_12", "guide.training_13", "guide.training_14", "guide.training_15", "guide.training_16", "guide.training_17", "guide.training_18", "guide.training_19", "guide.training_20", "guide.training_21", "guide.training_22", "guide.training_23", "guide.training_24", "guide.training_25", "guide.training_26"], "", { t })}
diff --git a/src/public_training.css b/src/public_training.css index 79baecc..01cd3f7 100644 --- a/src/public_training.css +++ b/src/public_training.css @@ -787,3 +787,82 @@ button { grid-template-columns: 1fr; } } + +.is-hidden { + display: none !important; +} + +.profile-chart-card { + margin-top: 14px; +} + +.profile-chart-card .profile-card-head p { + margin: 5px 0 0; + color: var(--muted); + font-size: 0.78rem; +} + +.profile-pace-chart { + padding: 10px 16px 16px; +} + +.profile-pace-chart svg { + display: block; + width: 100%; + max-height: 190px; + overflow: visible; + border-radius: 12px; + background: + linear-gradient(rgba(255, 255, 255, 0.045) 1px, transparent 1px) 0 0 / 100% 25%, + linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px) 0 0 / 20% 100%; +} + +.profile-pace-line { + fill: none; + stroke: var(--accent); + stroke-width: 5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.profile-pace-chart circle { + fill: var(--gold); + stroke: var(--surface); + stroke-width: 3; +} + +.profile-chart-labels { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 8px; + color: var(--muted); + font-size: 0.7rem; +} + +.profile-chart-labels strong { + color: var(--text); + font-family: Orbitron, sans-serif; +} + +.profile-stint-table-wrap { + padding: 4px 16px 16px; + border-top: 1px solid var(--line); +} + +.profile-stint-table-wrap h3 { + margin: 14px 0 8px; + font-family: Orbitron, sans-serif; + font-size: 0.86rem; +} + +@media (max-width: 620px) { + .profile-chart-card .profile-card-head { + align-items: flex-start; + } + + .profile-pace-chart { + padding: 8px 10px 14px; + } +} diff --git a/src/public_training.js b/src/public_training.js index e22325f..f0046b5 100644 --- a/src/public_training.js +++ b/src/public_training.js @@ -58,6 +58,12 @@ const messages = { switchCue: "Byt till kontrollerad körning", finishCue: "Träningspasset är klart", performance: "Stintstatistik", + paceChart: "Tempoutveckling", + paceChartHint: "Senaste träningsdagarnas snittempo i vald klass. Lägre är snabbare.", + stintDetails: "Senaste stintar", + stintNumber: "Stint", + duration: "Tid", + noStints: "Inga stintar registrerade ännu.", stints: "Stints", bestThree: "Bästa 3 sammanhängande", bestFive: "Bästa 5 min", @@ -164,6 +170,12 @@ const messages = { switchCue: "Switch to controlled driving", finishCue: "The training session is complete", performance: "Stint statistics", + paceChart: "Pace development", + paceChartHint: "Average pace for recent practice days in the selected class. Lower is faster.", + stintDetails: "Recent stints", + stintNumber: "Stint", + duration: "Duration", + noStints: "No stints have been recorded yet.", stints: "Stints", bestThree: "Best 3 consecutive", bestFive: "Best 5 min", @@ -308,13 +320,25 @@ function buildProfileMotivation(stats, channelStats = {}) { return { challenges, credits: credits.slice(0, 3) }; } +function renderPaceChart(points) { + const values = (Array.isArray(points) ? points : []).slice().reverse().filter((point) => Number(point.averageLapMs || 0) > 0); + if (values.length < 2) return `

${escapeHtml(t("collectingTrend"))}

`; + const width = 640, height = 180, padding = 24; + const lapTimes = values.map((point) => Number(point.averageLapMs)); + const min = Math.min(...lapTimes); + const range = Math.max(1, Math.max(...lapTimes) - min); + const coordinates = values.map((point, index) => ({ x: padding + (index / Math.max(1, values.length - 1)) * (width - padding * 2), y: padding + ((Number(point.averageLapMs) - min) / range) * (height - padding * 2), point })); + const path = coordinates.map((point) => `${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(" "); + return `
${coordinates.map((item) => `${escapeHtml(item.point.dateKey)}: ${escapeHtml(formatLap(item.point.averageLapMs))}`).join("")}
${escapeHtml(values[0].dateKey)}${escapeHtml(formatLap(values.at(-1).averageLapMs))}${escapeHtml(values.at(-1).dateKey)}
`; +} function renderProfile(data) { profileData = data; const { profile, branding } = data; + const visibility = { progress: true, achievements: true, performance: true, goals: true, history: true, ...(data.visibility || {}) }; const classProfiles = Array.isArray(data.classProfiles) ? data.classProfiles : []; let activeClass = classProfiles.find((item) => String(item.classId || "") === selectedClassId) || classProfiles[0] || null; if (activeClass && selectedClassId !== activeClass.classId) selectedClassId = activeClass.classId; - const stats = activeClass?.stats || data.stats; + const stats = { dailySummaries: [], paceHistory: [], achievements: [], stintSummaries: [], ...(activeClass?.stats || data.stats || {}) }; const channelStats = activeClass?.channelStats || data.channelStats || {}; const nextMilestone = activeClass?.nextMilestone || data.nextMilestone; const recommendation = activeClass?.recommendation || data.recommendation; @@ -380,6 +404,9 @@ function renderProfile(data) { `).join("") : `-`; + const stintRows = stats.stintSummaries.length ? stats.stintSummaries.slice(0, 6).map((stint) => ` + ${stint.number}${escapeHtml(new Date(stint.startedAt).toLocaleString(language, { dateStyle: "short", timeStyle: "short" }))}${stint.lapCount}${formatLap(stint.bestLapMs)}${formatLap(stint.averageLapMs)}${formatRaceResultTime(stint.durationMs)} + `).join("") : `${escapeHtml(t("noStints"))}`; const classOptions = classProfiles.map((item) => ( `` )).join(""); @@ -424,18 +451,22 @@ function renderProfile(data) { -
-
+
+
${escapeHtml(t("recentProgress"))} ${escapeHtml(t("averagePace"))}

${escapeHtml(paceTrend)}

-
+
${escapeHtml(t("achievements"))}${unlockedAchievements.length}/${achievements.length}
${achievementMarkup}
+
+

${escapeHtml(t("paceChart"))}

${escapeHtml(t("paceChartHint"))}

+ ${renderPaceChart(stats.paceHistory)} +

${escapeHtml(t("nextLevel"))}

@@ -479,8 +510,8 @@ function renderProfile(data) {
-
-

${escapeHtml(t("performance"))}

+
+

${escapeHtml(t("performance"))}

+
${escapeHtml(t("stints"))}${Number(stats.stintCount || 0)}
${escapeHtml(t("controlZone"))}${stats.controlZoneMaxMs ? `${formatLap(stats.controlZoneMinMs)} - ${formatLap(stats.controlZoneMaxMs)}` : "-"}
@@ -495,16 +526,20 @@ function renderProfile(data) {
${escapeHtml(t("pitStops"))}${Number(channelStats.pitStopCount || 0)}
${escapeHtml(t("totalPit"))}${channelStats.pitStopCount ? formatRaceResultTime(channelStats.totalPitMs) : "-"}
-
+
+

${escapeHtml(t("stintDetails"))}

+
${stintRows}
${escapeHtml(t("stintNumber"))}${escapeHtml(t("date"))}${escapeHtml(t("laps"))}${escapeHtml(t("best"))}${escapeHtml(t("average"))}${escapeHtml(t("duration"))}
+
+ - ${goals.length ? ` + ${visibility.goals && goals.length ? `

${escapeHtml(t("goals"))}

${goalMarkup}
` : ""} -
+

${escapeHtml(t("history"))}

+
diff --git a/src/styles.css b/src/styles.css index 3967174..49c32ae 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3963,3 +3963,17 @@ select:focus { .session-replay-progress { grid-template-columns: 1fr auto; } .session-replay-progress progress { grid-column: 1 / -1; } } + +.training-share-sections { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 8px 14px; + margin: 12px 0; + padding: 12px; + border: 1px solid var(--line); + border-radius: 12px; +} + +.training-share-sections legend { padding: 0 6px; font-weight: 700; } +.training-share-sections .hint { grid-column: 1 / -1; margin: 0 0 4px; } +.training-share-sections label { display: flex; align-items: center; gap: 8px; } diff --git a/src/training_logic.js b/src/training_logic.js index b9b6285..783adf3 100644 --- a/src/training_logic.js +++ b/src/training_logic.js @@ -1,5 +1,12 @@ import { getDriverProfile, getDriverProfileReferences } from "./driver_profile_logic.js"; +const PUBLIC_TRAINING_SECTIONS = ["progress", "achievements", "performance", "goals", "history"]; + +function normalizePublicTrainingVisibility(value) { + const source = value && typeof value === "object" ? value : {}; + return Object.fromEntries(PUBLIC_TRAINING_SECTIONS.map((key) => [key, source[key] !== false])); +} + function getTrainingDriverIds(state, driverId, classId = "") { const profile = getDriverProfile(state, driverId); if (!profile) return getDriverProfileReferences(state, driverId); @@ -77,6 +84,7 @@ export function normalizeTrainingState(training, { uid }) { token: String(share?.token || "").trim(), enabled: share?.enabled !== false, createdAt: String(share?.createdAt || new Date().toISOString()), + visibility: normalizePublicTrainingVisibility(share?.visibility), })) .filter((share) => { if (!share.driverId || share.token.length < 24 || seenShareDrivers.has(share.driverId)) return false; diff --git a/src/training_views.js b/src/training_views.js index 90783c5..05ecff8 100644 --- a/src/training_views.js +++ b/src/training_views.js @@ -449,6 +449,15 @@ export function renderTrainingView(deps) {

${escapeHtml(t("training.public_profile_help"))}

+ ${profileShare ? ` +
+ ${escapeHtml(t("training.public_sections"))} +

${escapeHtml(t("training.public_sections_hint"))}

+ ${["progress", "achievements", "performance", "goals", "history"].map((section) => ` + + `).join("")} +
+ ` : ""}