diff --git a/internal/ui/official_live.go b/internal/ui/official_live.go
index b1511fe..a770014 100644
--- a/internal/ui/official_live.go
+++ b/internal/ui/official_live.go
@@ -1616,34 +1616,14 @@ func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int, fpq bool,
tlaStr := tlaStyle.Render(padRight(tla, 4))
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
lastLapRaw := d.LastLapTime
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 {
lastLap = padRightVisible(stylePurple.Render(lastLapRaw), 10)
} else if d.LastLapPB {
@@ -1652,6 +1632,11 @@ func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int, fpq bool,
}
var row string
+ gapStr := m.renderGapStr(d, fpq)
+ if d.Retired {
+ gapStr = padRightVisible(styleMuted.Render("Retired"), 10)
+ }
+
if m.showSectors {
timeCol := lastLap
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[1]),
m.renderSector(d.Sectors[2]),
- m.renderGapStr(d, fpq))
+ gapStr)
} else if fpq {
// FP / Qualifying: show BEST lap as primary, LAST as secondary
bestLap := m.renderBestLapTime(d, overallBest)
@@ -1677,7 +1662,7 @@ func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int, fpq bool,
flyingIndicator,
bestLap,
lastLap,
- m.renderGapStr(d, fpq))
+ gapStr)
// Highlight danger zone (cutoff) in qualifying
if d.Cutoff && idx != m.cursor {
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,
m.renderTyreAge(d.RacingNumber),
lastLap,
- m.renderGapStr(d, fpq),
+ gapStr,
m.renderIntvStr(d),
padRightVisible(trend, 8))
}
+ // Removed lines are embedded in the previous chunk.
+
if idx == m.cursor {
return styleSelected.Render(row)
}
@@ -1762,13 +1749,19 @@ func (m OfficialLiveModel) renderTyreIndicator(num string) string {
}
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 := " "
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 {
diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js
index 453726d..adfdfd4 100644
--- a/internal/web/assets/app.js
+++ b/internal/web/assets/app.js
@@ -600,9 +600,44 @@ function livePage() {
},
get sortedDrivers() {
- return Object.values(this.drivers)
- .filter(d => d.Position > 0)
- .sort((a, b) => a.Position - b.Position);
+ const merged = {};
+ for (const num of Object.keys(this.drivers)) {
+ 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) {
diff --git a/internal/web/assets/index.html b/internal/web/assets/index.html
index 531e3c3..0a5610a 100644
--- a/internal/web/assets/index.html
+++ b/internal/web/assets/index.html
@@ -142,29 +142,31 @@
-
-
-
- | POS | DRIVER | TEAM | TIME / GAP | PTS |
-
-
-
-
-
- |
-
- DNF
- DNS
- DSQ
- |
- |
- |
- |
- |
+
+
No results available.
@@ -243,31 +245,36 @@
-
-
- | POS | Δ | DRIVER | TYRE | LAST LAP | GAP | BEST |
-
-
-
-
- |
- |
-
- PIT
- OUT
- |
-
-
- |
- |
- |
- |
-
-
-
-
+
diff --git a/internal/web/assets/style.css b/internal/web/assets/style.css
index 611b73f..db0a65c 100644
--- a/internal/web/assets/style.css
+++ b/internal/web/assets/style.css
@@ -566,8 +566,9 @@ a { color: inherit; text-decoration: none; }
font-size: 0.85rem;
}
.timing-tower {
- width: calc(100% - 3rem);
- margin: 0 1.5rem 1.5rem;
+ width: 100%;
+ max-width: 1100px;
+ margin: 0 auto 1.5rem;
border-collapse: collapse;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
@@ -585,7 +586,7 @@ a { color: inherit; text-decoration: none; }
}
.timing-tower td {
border-bottom: 1px solid var(--surface-3);
- padding: 0.45rem 0.75rem;
+ padding: 0.35rem 0.6rem;
vertical-align: middle;
}
.timing-tower .in-pit td { background: rgba(0,51,102,0.25); }
@@ -682,6 +683,7 @@ a { color: inherit; text-decoration: none; }
#laps-chart,
#telemetry-chart,
#track-chart {
+ overflow-x: auto;
padding: 0 1.5rem 1.5rem;
}
#strategy-chart svg,
@@ -879,3 +881,79 @@ a { color: inherit; text-decoration: none; }
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; }
+}
diff --git a/suggestions.md b/suggestions.md
new file mode 100644
index 0000000..3631ea9
--- /dev/null
+++ b/suggestions.md
@@ -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.