UI Updates and Web App

This commit is contained in:
2026-05-24 20:56:00 -04:00
parent b61f9f0685
commit 161e871c53
5 changed files with 246 additions and 84 deletions

View File

@@ -1616,34 +1616,14 @@ func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int, fpq bool,
tlaStr := tlaStyle.Render(padRight(tla, 4)) tlaStr := tlaStyle.Render(padRight(tla, 4))
tyreStr := m.renderTyreIndicator(d.RacingNumber) tyreStr := m.renderTyreIndicator(d.RacingNumber)
if d.Retired {
row := fmt.Sprintf(" %s %s %s %s %s %s",
posStr, deltaStr, colorBar, tlaStr,
styleDNF.Render("RET"), styleMuted.Render("Retired"))
if idx == m.cursor {
return styleSelected.Render(row)
}
if d.KnockedOut {
return styleMuted.Render(row)
}
return row
}
if d.InPit {
row := fmt.Sprintf(" %s %s %s %s %s %s %s",
posStr, deltaStr, colorBar, tlaStr, tyreStr,
styleSafetyCar.Render(padRight("PIT", 10)),
m.renderGapStr(d, fpq))
if idx == m.cursor {
return styleSelected.Render(row)
}
return row
}
// Last lap time coloring // Last lap time coloring
lastLapRaw := d.LastLapTime lastLapRaw := d.LastLapTime
lastLap := padRight(lastLapRaw, 10) lastLap := padRight(lastLapRaw, 10)
if lastLapRaw != "" { if d.Retired {
lastLap = padRightVisible(styleDNF.Render("RET"), 10)
} else if d.InPit {
lastLap = padRightVisible(styleSafetyCar.Render("PIT"), 10)
} else if lastLapRaw != "" {
if d.LastLapOB { if d.LastLapOB {
lastLap = padRightVisible(stylePurple.Render(lastLapRaw), 10) lastLap = padRightVisible(stylePurple.Render(lastLapRaw), 10)
} else if d.LastLapPB { } else if d.LastLapPB {
@@ -1652,6 +1632,11 @@ func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int, fpq bool,
} }
var row string var row string
gapStr := m.renderGapStr(d, fpq)
if d.Retired {
gapStr = padRightVisible(styleMuted.Render("Retired"), 10)
}
if m.showSectors { if m.showSectors {
timeCol := lastLap timeCol := lastLap
if fpq { if fpq {
@@ -1663,7 +1648,7 @@ func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int, fpq bool,
m.renderSector(d.Sectors[0]), m.renderSector(d.Sectors[0]),
m.renderSector(d.Sectors[1]), m.renderSector(d.Sectors[1]),
m.renderSector(d.Sectors[2]), m.renderSector(d.Sectors[2]),
m.renderGapStr(d, fpq)) gapStr)
} else if fpq { } else if fpq {
// FP / Qualifying: show BEST lap as primary, LAST as secondary // FP / Qualifying: show BEST lap as primary, LAST as secondary
bestLap := m.renderBestLapTime(d, overallBest) bestLap := m.renderBestLapTime(d, overallBest)
@@ -1677,7 +1662,7 @@ func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int, fpq bool,
flyingIndicator, flyingIndicator,
bestLap, bestLap,
lastLap, lastLap,
m.renderGapStr(d, fpq)) gapStr)
// Highlight danger zone (cutoff) in qualifying // Highlight danger zone (cutoff) in qualifying
if d.Cutoff && idx != m.cursor { if d.Cutoff && idx != m.cursor {
row = lipgloss.NewStyle().Foreground(lipgloss.Color(colorOrange)).Render(row) row = lipgloss.NewStyle().Foreground(lipgloss.Color(colorOrange)).Render(row)
@@ -1692,11 +1677,13 @@ func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int, fpq bool,
posStr, deltaStr, colorBar, tlaStr, tyreStr, posStr, deltaStr, colorBar, tlaStr, tyreStr,
m.renderTyreAge(d.RacingNumber), m.renderTyreAge(d.RacingNumber),
lastLap, lastLap,
m.renderGapStr(d, fpq), gapStr,
m.renderIntvStr(d), m.renderIntvStr(d),
padRightVisible(trend, 8)) padRightVisible(trend, 8))
} }
// Removed lines are embedded in the previous chunk.
if idx == m.cursor { if idx == m.cursor {
return styleSelected.Render(row) return styleSelected.Render(row)
} }
@@ -1762,13 +1749,19 @@ func (m OfficialLiveModel) renderTyreIndicator(num string) string {
} }
compound := strings.ToUpper(tyre.Compound) compound := strings.ToUpper(tyre.Compound)
abbrev, style := compoundAbbrevStyle(compound) abbrev, fgStyle := compoundAbbrevStyle(compound)
bgStyle := lipgloss.NewStyle().Background(fgStyle.GetForeground()).Foreground(lipgloss.Color("#000")).Bold(true)
if strings.Contains(compound, "WET") {
bgStyle = bgStyle.Foreground(lipgloss.Color("#fff"))
}
newMark := " " newMark := " "
if tyre.New { if tyre.New {
newMark = style.Render("*") newMark = "*"
} }
return padRightVisible(style.Render("●")+" "+abbrev+newMark, 5) text := fmt.Sprintf(" %s%s ", abbrev, newMark)
return padRightVisible(bgStyle.Render(text), 5)
} }
func (m OfficialLiveModel) renderTyreAge(num string) string { func (m OfficialLiveModel) renderTyreAge(num string) string {

View File

@@ -600,9 +600,44 @@ function livePage() {
}, },
get sortedDrivers() { get sortedDrivers() {
return Object.values(this.drivers) const merged = {};
.filter(d => d.Position > 0) for (const num of Object.keys(this.drivers)) {
.sort((a, b) => a.Position - b.Position); merged[num] = { ...this.drivers[num] };
}
for (const num of Object.keys(this.driverInfo)) {
if (!merged[num]) {
merged[num] = { RacingNumber: num, Position: 0 };
}
}
const all = Object.values(merged);
all.sort((a, b) => {
const pi = a.Position || 0;
const pj = b.Position || 0;
if (pi > 0 && pj > 0) return pi - pj;
if (pi > 0) return -1;
if (pj > 0) return 1;
const ti = a.BestLapTime || '';
const tj = b.BestLapTime || '';
if (ti && tj) return ti.localeCompare(tj);
if (ti) return -1;
if (tj) return 1;
const ni = parseInt(a.RacingNumber) || 0;
const nj = parseInt(b.RacingNumber) || 0;
return ni - nj;
});
all.forEach((d, i) => {
if (!d.Position) {
d.Position = i + 1;
}
});
return all;
}, },
driverTla(num) { driverTla(num) {

View File

@@ -142,29 +142,31 @@
<div class="skeleton" style="height:36px;margin-bottom:4px;"></div> <div class="skeleton" style="height:36px;margin-bottom:4px;"></div>
</template> </template>
</div> </div>
<table class="data-table" x-show="!resultsLoading && results.length"> <div class="table-scroll" x-show="!resultsLoading && results.length">
<thead> <table class="data-table">
<tr> <thead>
<th>POS</th><th>DRIVER</th><th>TEAM</th><th>TIME / GAP</th><th>PTS</th> <tr>
</tr> <th>POS</th><th>DRIVER</th><th>TEAM</th><th>TIME / GAP</th><th>PTS</th>
</thead>
<tbody>
<template x-for="r in results" :key="r.driver_number">
<tr :style="`--team-color:#${r.team_colour}`">
<td class="pos-cell">
<span x-text="r.position||'-'"></span>
<span x-show="r.dnf" class="badge dnf">DNF</span>
<span x-show="r.dns" class="badge dns">DNS</span>
<span x-show="r.dsq" class="badge dsq">DSQ</span>
</td>
<td><span class="team-swatch"></span><span x-text="r.full_name||r.driver_number"></span></td>
<td x-text="r.team_name||'-'"></td>
<td x-text="fmtDuration(r.duration)"></td>
<td x-text="r.points||0"></td>
</tr> </tr>
</template> </thead>
</tbody> <tbody>
</table> <template x-for="r in results" :key="r.driver_number">
<tr :style="`--team-color:#${r.team_colour}`">
<td class="pos-cell">
<span x-text="r.position||'-'"></span>
<span x-show="r.dnf" class="badge dnf">DNF</span>
<span x-show="r.dns" class="badge dns">DNS</span>
<span x-show="r.dsq" class="badge dsq">DSQ</span>
</td>
<td><span class="team-swatch"></span><span x-text="r.full_name||r.driver_number"></span></td>
<td x-text="r.team_name||'-'"></td>
<td x-text="fmtDuration(r.duration)"></td>
<td x-text="r.points||0"></td>
</tr>
</template>
</tbody>
</table>
</div>
<div x-show="!resultsLoading && !results.length" class="empty-msg">No results available.</div> <div x-show="!resultsLoading && !results.length" class="empty-msg">No results available.</div>
</div> </div>
@@ -243,31 +245,36 @@
<span class="clock" x-text="clockDisplay"></span> <span class="clock" x-text="clockDisplay"></span>
</div> </div>
<table class="timing-tower"> <div class="table-scroll">
<thead> <table class="timing-tower">
<tr><th>POS</th><th>Δ</th><th>DRIVER</th><th>TYRE</th><th>LAST LAP</th><th>GAP</th><th>BEST</th></tr> <thead>
</thead> <tr><th>POS</th><th>Δ</th><th>DRIVER</th><th>TYRE</th><th>LAST LAP</th><th>GAP</th><th>BEST</th></tr>
<tbody> </thead>
<template x-for="d in sortedDrivers" :key="d.RacingNumber"> <tbody>
<tr :class="{in-pit: d.InPit, retired: d.Retired, pit-out: d.PitOut}" <template x-for="d in sortedDrivers" :key="d.RacingNumber">
:style="`--team-color:#${driverTeamColor(d.RacingNumber)}`"> <tr :class="{in-pit: d.InPit, retired: d.Retired, pit-out: d.PitOut}"
<td class="pos-cell" x-text="d.Position"></td> :style="`--team-color:#${driverTeamColor(d.RacingNumber)}`">
<td class="pos-delta" x-text="posDelta(d)"></td> <td class="pos-cell" x-text="d.Position"></td>
<td><span class="team-swatch"></span><span x-text="driverTla(d.RacingNumber)"></span> <td class="pos-delta" x-text="posDelta(d)"></td>
<span x-show="d.InPit" class="badge pit">PIT</span> <td>
<span x-show="d.Retired" class="badge out">OUT</span> <div class="driver-cell">
</td> <span class="team-swatch"></span><span x-text="driverTla(d.RacingNumber)"></span>
<td> <span x-show="d.InPit" class="badge pit">PIT</span>
<span class="tyre-badge" :class="tyreClass(d.RacingNumber)" <span x-show="d.Retired" class="badge out">OUT</span>
x-text="tyreLabel(d.RacingNumber)"></span> </div>
</td> </td>
<td :class="{pb: d.LastLapPB, ob: d.LastLapOB}" x-text="d.LastLapTime||'-'"></td> <td>
<td x-text="d.GapToLeader||'-'"></td> <span class="tyre-badge" :class="tyreClass(d.RacingNumber)"
<td :class="{ob: d.BestLapOB}" x-text="d.BestLapTime||'-'"></td> x-text="tyreLabel(d.RacingNumber)"></span>
</tr> </td>
</template> <td :class="{pb: d.LastLapPB, ob: d.LastLapOB}" x-text="d.LastLapTime||'-'"></td>
</tbody> <td x-text="d.GapToLeader||'-'"></td>
</table> <td :class="{ob: d.BestLapOB}" x-text="d.BestLapTime||'-'"></td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Race control messages --> <!-- Race control messages -->
<div class="rc-panel" x-show="rcMessages.length"> <div class="rc-panel" x-show="rcMessages.length">

View File

@@ -566,8 +566,9 @@ a { color: inherit; text-decoration: none; }
font-size: 0.85rem; font-size: 0.85rem;
} }
.timing-tower { .timing-tower {
width: calc(100% - 3rem); width: 100%;
margin: 0 1.5rem 1.5rem; max-width: 1100px;
margin: 0 auto 1.5rem;
border-collapse: collapse; border-collapse: collapse;
font-size: 0.85rem; font-size: 0.85rem;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -585,7 +586,7 @@ a { color: inherit; text-decoration: none; }
} }
.timing-tower td { .timing-tower td {
border-bottom: 1px solid var(--surface-3); border-bottom: 1px solid var(--surface-3);
padding: 0.45rem 0.75rem; padding: 0.35rem 0.6rem;
vertical-align: middle; vertical-align: middle;
} }
.timing-tower .in-pit td { background: rgba(0,51,102,0.25); } .timing-tower .in-pit td { background: rgba(0,51,102,0.25); }
@@ -682,6 +683,7 @@ a { color: inherit; text-decoration: none; }
#laps-chart, #laps-chart,
#telemetry-chart, #telemetry-chart,
#track-chart { #track-chart {
overflow-x: auto;
padding: 0 1.5rem 1.5rem; padding: 0 1.5rem 1.5rem;
} }
#strategy-chart svg, #strategy-chart svg,
@@ -879,3 +881,79 @@ a { color: inherit; text-decoration: none; }
grid-template-columns: 44px 1fr 80px 55px 55px; grid-template-columns: 44px 1fr 80px 55px 55px;
} }
} }
/* ---- Scrollable table wrapper ---- */
.table-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
margin: 0 1.5rem 1.5rem;
}
.table-scroll .data-table,
.table-scroll .timing-tower {
margin: 0;
width: 100%;
}
/* ============================================================
Mobile responsive overrides (≤ 640px)
============================================================ */
@media (max-width: 640px) {
/* Reduce global horizontal padding */
.home-hero { padding: 0.75rem; }
.meetings-grid { padding: 0 0.75rem 1.5rem; }
.section-label { padding: 0.75rem 0.75rem 0.5rem; }
.page-header { padding: 0.75rem 0.75rem 0.5rem; flex-wrap: wrap; gap: 0.5rem; }
.year-selector { margin-left: 0; }
.session-pills { padding: 0.5rem 0.75rem; }
.driver-selector { padding: 0.5rem 0.75rem 0.75rem; }
.table-scroll { margin: 0 0.75rem 1.5rem; }
.rc-panel { margin: 0 0.75rem 1.5rem; }
.standings-table { padding: 0 0.75rem 1.5rem; }
.skeleton-wrap { padding: 0.75rem; }
.empty-msg { padding: 2rem 0.75rem; }
/* Charts */
#strategy-chart,
#laps-chart,
#telemetry-chart,
#track-chart { padding: 0 0.75rem 1.5rem; }
/* Nav */
.nav { gap: 0.75rem; padding: 0.6rem 0.75rem; }
/* Tabs — allow horizontal scroll so all tabs are reachable */
.tabs { overflow-x: auto; padding: 0 0.75rem; scrollbar-width: none; }
.tabs::-webkit-scrollbar { display: none; }
.tab { padding: 0.6rem 0.75rem; font-size: 0.78rem; white-space: nowrap; }
/* Stale banner */
.stale-banner { padding: 0.5rem 0.75rem; flex-wrap: wrap; }
/* Live timing meta row */
.live-meta { gap: 0.5rem; padding: 0.5rem 0.75rem; flex-wrap: wrap; }
.clock { margin-left: 0; }
/* Tighten table cell padding */
.data-table th,
.data-table td { padding: 0.6rem 0.6rem; }
.timing-tower th,
.timing-tower td { padding: 0.4rem 0.5rem; }
/* Hide TEAM column in race results table */
.data-table th:nth-child(3),
.data-table td:nth-child(3) { display: none; }
/* Hide BEST LAP column in timing tower */
.timing-tower th:nth-child(7),
.timing-tower td:nth-child(7) { display: none; }
/* Scale down hero text */
.next-race-name { font-size: 1.35rem; }
.next-race-countdown { font-size: 1.6rem; }
}
@media (max-width: 420px) {
/* Hide position delta column on very small phones to save space */
.timing-tower th:nth-child(2),
.timing-tower td:nth-child(2) { display: none; }
}

49
suggestions.md Normal file
View File

@@ -0,0 +1,49 @@
# Evolution Roadmap: box-box for F1 Fanatics
This document outlines the strategic evolution of **box-box** to transition from a high-quality dashboard to a "Mission Control" and "Analysis Lab" for hardcore F1 fans.
---
## 1. Live Session: "Mission Control"
*Goal: Enhance the viewing experience with real-time strategist-grade insights.*
### High-Impact Features
* **Command Center View**: A unified, dense dashboard combining the live timing tower, auto-detected battles, track status, weather, and race control into one glanceable screen.
* **Quali Hunter Mode**: Specifically for qualifying—tracking "banker" vs "push" laps, purple/green sector watches, traffic density alerts, and live "on the bubble" cutoff lines.
* **Strategy Watch**: Real-time undercut/overcut threat detection. Visualizing the "pit window" not just as a static number, but as a dynamic "rejoin position" gap on the timing tower.
* **Focus Mode**: Ability to "pin" 2-3 drivers to the top of the tower to monitor their specific gap trends, tyre age offsets, and lap-time deltas.
* **Chaos Meter**: A heuristic-driven indicator of "likelihood of a safety car/incident" based on bunching, weather shifts, and recent yellow sector frequency.
---
## 2. Between GPs: "The Analysis Lab"
*Goal: Provide deep-dive tools for "nerding out" on data during the off-week.*
### High-Impact Features
* **Telemetry Lab**: Side-by-side comparison of two drivers or two laps using `CarData` (Speed, Throttle, Brake, Gear, DRS). Visualized as synchronized traces.
* **Stint & Tyre Explorer**: Detailed degradation curves. Visualizing pace drop-off over a stint and comparing "Tyre Whisperer" performance between teammates.
* **Season Storylines**: Progression charts for points, qualifying head-to-heads, and "average finish" trends across the season.
* **Driver Dossiers**: Historical performance profiles—which tracks a driver excels at, their wet-weather "rating," and comeback statistics (positions gained).
* **Track DNA**: Corner-speed profiles and overtaking difficulty maps for every circuit on the calendar.
---
## 3. UI/UX & Technical Refinements
*Goal: Professionalize the "Mission Control" aesthetic and improve ergonomics.*
* **Layout Toggles**: Switch between `Broadcast` (clean, high-level), `Dense` (standard dashboard), and `Analyst` (maximum data density).
* **Event Rail**: A horizontal timeline of the session showing Pits, Overtakes, Yellow Flags, and Team Radio icons.
* **Aggressive Pre-fetching**: Use the time *before* the 30-minute API lockout to cache all necessary historical context, track maps, and driver bios to keep the app rich during live sessions.
* **Narrative Heuristics**: Auto-generated status strings like "Verstappen is in the hunting phase" or "Hamilton tyre cliff imminent" based on lap-time trends.
---
## 4. Senior Engineer's "Top Pick" (Most Benefit)
If we were to prioritize only one major evolution, the **Telemetry Lab (Telemetry + Stint Comparison)** brings the most unique value to a TUI.
**Why?**
1. **Unique Value**: Most free apps show results; almost none show interactive terminal-based telemetry traces.
2. **Code Readiness**: The `CarData` and `Lap` models are already in the codebase. The API client already knows how to fetch them.
3. **Fan Engagement**: It turns the app from a "during the race" tool into a "all week long" tool. Fans love debating *where* a driver lost time (e.g., "He was 5km/h slower through Turn 4"), and a Telemetry Lab proves it.
4. **Technical Sophistication**: It demonstrates the power of the Go/Bubble Tea stack to handle dense, high-frequency data visualizations in a terminal.