förbättreing tränings

This commit is contained in:
larssand
2026-08-28 21:44:23 +02:00
parent c6262aceab
commit 375bfb2416
14 changed files with 420 additions and 54 deletions

View File

@@ -137,15 +137,42 @@ export function renderTrainingView(deps) {
</tr>
`).join("")
: `<tr><td colspan="4">${escapeHtml(t("training.no_history"))}</td></tr>`;
const unlockedAchievements = (stats.achievements || []).filter((achievement) => achievement.unlocked);
const nextAchievement = (stats.achievements || []).find((achievement) => !achievement.unlocked);
const visibleAchievements = [...unlockedAchievements.slice(-5), ...(nextAchievement ? [nextAchievement] : [])];
const achievementMarkup = visibleAchievements.length
? visibleAchievements.map((achievement) => `
<article class="training-achievement ${achievement.unlocked ? "is-unlocked" : "is-next"}">
<span class="training-achievement-mark">${achievement.unlocked ? "OK" : `${Math.round(achievement.percent)}%`}</span>
<div>
<strong>${escapeHtml(t(`training.achievement_${achievement.id}`))}</strong>
<small>${escapeHtml(t(achievement.unlocked ? "training.achievement_unlocked" : "training.achievement_next"))}</small>
</div>
</article>
`).join("")
: `<p class="hint">${escapeHtml(t("training.achievement_empty"))}</p>`;
const paceTrend = !stats.trendSampleReady
? t("training.trend_collecting")
: stats.averageTrendMs > 0
? t("training.trend_faster", { lap: formatLap(stats.averageTrendMs) })
: stats.averageTrendMs < 0
? t("training.trend_slower", { lap: formatLap(Math.abs(stats.averageTrendMs)) })
: t("training.trend_stable");
const consistencyTrend = !stats.trendSampleReady
? t("training.trend_collecting_short")
: stats.consistencyTrendPct > 0.1
? t("training.consistency_better", { value: stats.consistencyTrendPct.toFixed(1) })
: stats.consistencyTrendPct < -0.1
? t("training.consistency_building")
: t("training.trend_stable");
const clubRows = profiles
.filter((profile) => !selectedClassId || profile.classIds.includes(selectedClassId))
.map((profile) => ({ profile, driver: profile.representative, stats: profile.id === selectedProfileId ? stats : buildDriverTrainingStats(state, profile.id, selectedClassId) }))
.sort((left, right) => right.stats.totalLaps - left.stats.totalLaps || String(left.driver?.name || "").localeCompare(String(right.driver?.name || "")))
.map((entry, index) => {
.sort((left, right) => right.stats.lastLapAt - left.stats.lastLapAt || String(left.driver?.name || "").localeCompare(String(right.driver?.name || "")))
.map((entry) => {
const milestone = getNextTrainingMilestone(entry.stats);
return `
<tr class="${entry.profile.id === selectedProfileId ? "is-selected" : ""}">
<td>${index + 1}</td>
<td><button class="training-club-driver" data-driver-id="${escapeHtml(entry.profile.id)}" data-class-id="${escapeHtml(selectedClassId)}" type="button">${escapeHtml(entry.driver?.name || "-")}</button></td>
<td>${entry.stats.totalLaps}</td>
<td>${entry.stats.trainingDays}</td>
@@ -209,6 +236,27 @@ export function renderTrainingView(deps) {
${trainingStat(t("training.consistency"), stats.recentLapCount ? `${stats.consistencySpreadPct.toFixed(1)}%` : "-", t("training.last_30_laps"))}
</div>
<div class="training-development-grid">
<section class="training-development-card">
<div class="training-section-title">
<div><span class="training-kicker">${escapeHtml(t("training.your_progress"))}</span><h3>${escapeHtml(t("training.recent_trend"))}</h3></div>
<span class="badge">30 + 30 ${escapeHtml(t("training.laps").toLowerCase())}</span>
</div>
<div class="training-trend-metrics">
${trainingInsight(t("training.pace_trend"), paceTrend)}
${trainingInsight(t("training.consistency_trend"), consistencyTrend)}
</div>
<p>${escapeHtml(t("training.trend_hint"))}</p>
</section>
<section class="training-development-card">
<div class="training-section-title">
<div><span class="training-kicker">${escapeHtml(t("training.personal_credit"))}</span><h3>${escapeHtml(t("training.achievements"))}</h3></div>
<span class="badge success">${unlockedAchievements.length}/${(stats.achievements || []).length}</span>
</div>
<div class="training-achievements">${achievementMarkup}</div>
</section>
</div>
<div class="training-focus-grid">
<section class="training-milestone-card">
<div>
@@ -347,7 +395,7 @@ export function renderTrainingView(deps) {
</summary>
<div class="training-disclosure-body table-wrap training-history-wrap">
<table class="training-history-table training-club-table">
<thead><tr><th>#</th><th>${escapeHtml(t("training.driver"))}</th><th>${escapeHtml(t("training.laps"))}</th><th>${escapeHtml(t("training.training_days"))}</th><th>${escapeHtml(t("training.best_lap"))}</th><th>${escapeHtml(t("training.next_target"))}</th></tr></thead>
<thead><tr><th>${escapeHtml(t("training.driver"))}</th><th>${escapeHtml(t("training.laps"))}</th><th>${escapeHtml(t("training.training_days"))}</th><th>${escapeHtml(t("training.best_lap"))}</th><th>${escapeHtml(t("training.next_target"))}</th></tr></thead>
<tbody>${clubRows}</tbody>
</table>
</div>
@@ -470,28 +518,62 @@ export function buildTrainingOverviewRows(state, profiles, buildDriverTrainingSt
stats: buildDriverTrainingStats(state, profile.id, classId),
}));
}).sort((left, right) => (
right.stats.totalLaps - left.stats.totalLaps
right.stats.lastLapAt - left.stats.lastLapAt
|| String(left.driver?.name || "").localeCompare(String(right.driver?.name || ""))
|| String(left.className).localeCompare(String(right.className))
));
}
export function buildTrainingProgressFeed(rows, limit = 6) {
return (Array.isArray(rows) ? rows : [])
.filter((row) => Number(row?.stats?.totalLaps || 0) > 0)
.sort((left, right) => Number(right.stats.lastLapAt || 0) - Number(left.stats.lastLapAt || 0))
.slice(0, Math.max(1, Number(limit || 6)))
.map((row) => {
const stats = row.stats;
if (stats.personalBestGainMs > 0) return { ...row, kind: "personal_best", value: stats.personalBestGainMs };
if (stats.trendSampleReady && stats.averageTrendMs > 0) return { ...row, kind: "average_gain", value: stats.averageTrendMs };
if (stats.bestFiveMinuteResult) return { ...row, kind: "five_minute", value: stats.bestFiveMinuteResult.lapCount };
if (stats.controlledLapStreak >= 5) return { ...row, kind: "controlled_streak", value: stats.controlledLapStreak };
return { ...row, kind: "practice_laps", value: stats.totalLaps };
});
}
function renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, profiles, buildDriverTrainingStats, getNextTrainingMilestone, setSelectedDriverId, setSelectedClassId, renderView }) {
const rows = buildTrainingOverviewRows(state, profiles, buildDriverTrainingStats);
const progressFeed = buildTrainingProgressFeed(rows);
const ledger = Array.isArray(state.training?.ledger) ? state.training.ledger : [];
const activeProfiles = new Set(rows.filter((row) => row.stats.totalLaps > 0).map((row) => row.profile.id)).size;
const recordedSessions = new Set(ledger.map((entry) => String(entry.sessionId || "")).filter(Boolean)).size;
const driverOptions = profiles.map((profile) => (
`<option value="${escapeHtml(profile.id)}">${escapeHtml(profile.representative?.name || "-")}</option>`
)).join("");
const tableRows = rows.map((row, index) => {
const progressFeedMarkup = progressFeed.length
? progressFeed.map((entry) => {
const message = entry.kind === "personal_best"
? t("training.feed_personal_best", { lap: formatLap(entry.value) })
: entry.kind === "average_gain"
? t("training.feed_average_gain", { lap: formatLap(entry.value) })
: entry.kind === "five_minute"
? t("training.feed_five_minute", { laps: entry.value })
: entry.kind === "controlled_streak"
? t("training.feed_controlled_streak", { laps: entry.value })
: t("training.feed_practice_laps", { laps: entry.value });
return `
<button class="training-progress-entry training-club-driver" data-driver-id="${escapeHtml(entry.profile.id)}" data-class-id="${escapeHtml(entry.classId)}" type="button">
<span class="training-progress-initial">${escapeHtml(String(entry.driver?.name || "?").trim().charAt(0).toUpperCase() || "?")}</span>
<span><strong>${escapeHtml(entry.driver?.name || "-")}</strong><small>${escapeHtml(entry.className)} · ${escapeHtml(message)}</small></span>
</button>
`;
}).join("")
: `<p class="hint">${escapeHtml(t("training.feed_empty"))}</p>`;
const tableRows = rows.map((row) => {
const milestone = getNextTrainingMilestone(row.stats);
const lastActivity = row.stats.lastLapAt
? new Date(row.stats.lastLapAt).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })
: "-";
return `
<tr>
<td>${index + 1}</td>
<td><button class="training-club-driver" data-driver-id="${escapeHtml(row.profile.id)}" data-class-id="${escapeHtml(row.classId)}" type="button">${escapeHtml(row.driver?.name || "-")}</button></td>
<td>${escapeHtml(row.className)}</td>
<td>${row.stats.totalLaps}</td>
@@ -528,13 +610,18 @@ function renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, profiles
${trainingStat(t("training.sessions"), String(recordedSessions), t("training.recorded_sessions"))}
</div>
<section class="panel mt-16 training-progress-panel">
<div class="panel-header"><div><span class="training-kicker">${escapeHtml(t("training.progress_not_positions"))}</span><h3>${escapeHtml(t("training.progress_feed"))}</h3></div></div>
<div class="panel-body training-progress-feed">${progressFeedMarkup}</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${escapeHtml(t("training.club_progress"))}</h3><span class="badge">${rows.length}</span></div>
<div class="panel-body">
<p class="hint">${escapeHtml(t("training.overview_class_hint"))}</p>
<div class="table-wrap training-history-wrap">
<table class="training-history-table training-club-table">
<thead><tr><th>#</th><th>${escapeHtml(t("training.driver"))}</th><th>${escapeHtml(t("training.class"))}</th><th>${escapeHtml(t("training.laps"))}</th><th>${escapeHtml(t("training.training_days"))}</th><th>${escapeHtml(t("training.best_lap"))}</th><th>${escapeHtml(t("training.last_activity"))}</th><th>${escapeHtml(t("training.next_target"))}</th></tr></thead>
<thead><tr><th>${escapeHtml(t("training.driver"))}</th><th>${escapeHtml(t("training.class"))}</th><th>${escapeHtml(t("training.laps"))}</th><th>${escapeHtml(t("training.training_days"))}</th><th>${escapeHtml(t("training.best_lap"))}</th><th>${escapeHtml(t("training.last_activity"))}</th><th>${escapeHtml(t("training.next_target"))}</th></tr></thead>
<tbody>${tableRows}</tbody>
</table>
</div>