feat(server): integrate UDP video service and normalize database schema

- Implement UDPVideoService to stream MJPEG video frames from ESP32s via UDP and send control commands.
- Normalize race database schema:
  - Remove redundant driver_ids array from races table (migration 011).
  - Move per-driver outcomes (total_time_ms, best_lap_ms, position) to race_drivers table (migration 012).
  - Add device_id column to lobby_drivers to link active physical devices (migration 013).
- Update WebSocket handler to pass lobbySvc, driversSvc, and videoSvc for driver identity tracking, syncing in-memory driver metadata, and forwarding WS controls to physical cars.
- Add CORS middleware to HTTP routes.
- Regenerate Swagger documentation.
This commit is contained in:
x0gp
2026-07-06 12:38:34 +04:00
parent 978d36c505
commit 49351b4928
19 changed files with 1406 additions and 132 deletions
+30 -28
View File
@@ -394,9 +394,9 @@ func (r *Runner) makeFinished(idx int) races.FinishedRace {
}
bestLapMs := int64(20_000 + rng.Intn(8_000))
// Podium: top-3 by total time. The winner always finishes; the
// remaining drivers are sorted by a per-driver total time derived
// from the winner's best lap.
// Per-driver total time. The winner is fastest; the rest are
// winner's time + 2..16 seconds. Build the Results slice with
// computed positions.
perDriver := make([]int64, len(picked))
for i, d := range picked {
if d.ID == winner.ID {
@@ -419,35 +419,35 @@ func (r *Runner) makeFinished(idx int) races.FinishedRace {
all[j-1], all[j] = all[j], all[j-1]
}
}
podium := make([]races.PodiumEntry, 0, 3)
for i := 0; i < len(all) && i < 3; i++ {
podium = append(podium, races.PodiumEntry{
Position: i + 1,
DriverID: all[i].d.ID,
Name: all[i].d.Nickname,
TotalTimeMs: all[i].time,
results := make([]races.DriverResult, 0, len(all))
for i, row := range all {
pos := i + 1
// Podium only for the top-3; the rest are just DNF-ish finishers
// (they have a time and a position).
results = append(results, races.DriverResult{
DriverID: row.d.ID,
TotalTimeMs: row.time,
BestLapMs: bestLapMs,
Position: &pos,
})
}
return races.FinishedRace{
ID: fmt.Sprintf("seed-finished-%03d", idx+1),
Name: fmt.Sprintf("Seed Race #%d (%s)", idx+1, track),
TrackID: track,
MaxCars: 4,
Laps: laps,
TimeLimitS: 0,
DriverIDs: driverIDs,
Status: "finished",
CreatedMs: finishedMs - randDuration(rng, 5, 120).Milliseconds(),
StartedMs: startedMs,
FinishedMs: finishedMs,
DurationMs: finishedMs - startedMs,
TotalLaps: totalLaps,
TotalDrivers: len(driverIDs),
WinnerDriverID: winner.ID,
WinnerName: winner.Nickname,
BestLapMs: bestLapMs,
Podium: podium,
ID: fmt.Sprintf("seed-finished-%03d", idx+1),
Name: fmt.Sprintf("Seed Race #%d (%s)", idx+1, track),
TrackID: track,
MaxCars: 4,
Laps: laps,
TimeLimitS: 0,
DriverIDs: driverIDs,
Status: "finished",
CreatedMs: finishedMs - randDuration(rng, 5, 120).Milliseconds(),
StartedMs: startedMs,
FinishedMs: finishedMs,
DurationMs: finishedMs - startedMs,
TotalLaps: totalLaps,
TotalDrivers: len(driverIDs),
Results: results,
}
}
@@ -481,6 +481,8 @@ func (r *Runner) createLive(status lobby.RaceStatus, idx int) (lobby.RaceMeta, e
continue
}
r.lb.SetDriverProfile(d.ID, d.Nickname, "", d.ClanID, d.ClanTag)
devID := i
_ = r.lb.SelectDevice(d.ID, &devID)
if err := r.lb.AddDriverToRace(d.ID, meta.ID); err != nil {
// ignore: race may be at capacity
}
+104 -20
View File
@@ -62,6 +62,9 @@ func (s *Service) RestoreFromDB(ctx context.Context) (int, int, error) {
// AddDriver+SetDriverProfile and accept a no-op mirror.
_, _ = s.lobby.AddDriver(d.ID, d.Name, "")
s.lobby.SetDriverProfile(d.ID, d.Nickname, d.AvatarURL, d.ClanID, d.ClanTag)
if d.DeviceID != nil {
_ = s.lobby.SelectDevice(d.ID, d.DeviceID)
}
}
for _, r := range races {
// We bypass CreateRace to avoid duplicating persistence writes
@@ -106,6 +109,7 @@ type RaceItem struct {
type ListResult struct {
Items []RaceItem
NextCursor string // empty when no more pages
TotalCount int
}
// ListRaces returns one keyset page of races matching the filter.
@@ -186,7 +190,34 @@ func (s *Service) ListRaces(ctx context.Context, f ListFilter) (ListResult, erro
out = out[:limit]
}
res := ListResult{Items: out}
var totalCount int
sqlStatuses := getSQLStatuses(f.Statuses)
if s.live != nil {
var err error
totalCount, err = s.pg.CountRaces(ctx, sqlStatuses, f.TrackID)
if err != nil {
return ListResult{}, err
}
} else {
// Fallback: in-memory live + pg finished
liveCount := 0
for _, r := range s.lobby.ListRaces() {
if statusMatches(f.Statuses, string(r.Status)) && (f.TrackID == "" || r.TrackID == f.TrackID) {
liveCount++
}
}
var finishedCount int
if statusWantsFinished(f.Statuses) {
var err error
finishedCount, err = s.pg.CountRaces(ctx, []string{"finished", "cancelled"}, f.TrackID)
if err != nil {
return ListResult{}, err
}
}
totalCount = liveCount + finishedCount
}
res := ListResult{Items: out, TotalCount: totalCount}
if hasMore && len(out) > 0 {
last := out[len(out)-1]
res.NextCursor = Cursor{
@@ -292,6 +323,37 @@ func filterToStatusStrings(filters []StatusFilter) []string {
return out
}
func getSQLStatuses(filters []StatusFilter) []string {
var sqlStatuses []string
wantLive := false
wantFinished := false
if len(filters) == 0 {
wantLive = true
wantFinished = true
} else {
for _, f := range filters {
if f == StatusAll {
wantLive = true
wantFinished = true
break
}
if f.matchesLive("finished") || f == StatusFinished {
wantFinished = true
}
if f.matchesLive("lobby") || f == StatusLobby || f == StatusRacing {
wantLive = true
}
}
}
if wantLive {
sqlStatuses = append(sqlStatuses, filterToStatusStrings(filters)...)
}
if wantFinished {
sqlStatuses = append(sqlStatuses, "finished", "cancelled")
}
return sqlStatuses
}
func (s *Service) collectFinished(ctx context.Context, filters []StatusFilter, trackID, curSource string, curMs int64, curID string, limit int) ([]RaceItem, error) {
if !statusWantsFinished(filters) {
return nil, nil
@@ -488,8 +550,11 @@ func (s *Service) ListQueue(ctx context.Context, driverID string) ([]QueueEntry,
// Finished-race snapshot hook
// ---------------------------------------------------------------------------
// SnapshotFinished is the input to the "persist on finish" hook. Race is
// a copy of the in-memory meta. Result is filled by the engine.
// SnapshotFinished is the input to the "persist on finish" hook.
// The engine fills Meta, FinishedMs, TotalLaps and either:
// * Results (full per-driver totals / positions), or
// * the legacy WinnerDriverID + BestLapMs fallback (used by the
// seeder and any caller that doesn't track per-driver times).
type SnapshotFinished struct {
Meta lobby.RaceMeta
FinishedMs int64
@@ -497,6 +562,7 @@ type SnapshotFinished struct {
WinnerDriverID string
WinnerName string
BestLapMs int64
Results []DriverResult
}
// PersistFinished upserts the finished race into Postgres.
@@ -506,24 +572,37 @@ func (s *Service) PersistFinished(ctx context.Context, sf SnapshotFinished) erro
if finishedMs == 0 {
finishedMs = s.now().UnixMilli()
}
// Build the per-driver results from the in-memory race meta and
// the snapshot. In PoC we don't have per-driver totals from the
// engine yet, so we use the snapshot's WinnerDriverID as a
// single-row fallback when Results is empty. The full per-driver
// totals will be supplied by the engine in a future iteration.
results := sf.Results
if len(results) == 0 && sf.WinnerDriverID != "" {
pos := 1
results = []DriverResult{{
DriverID: sf.WinnerDriverID,
TotalTimeMs: finishedMs - startedMs,
BestLapMs: sf.BestLapMs,
Position: &pos,
}}
}
r := FinishedRace{
ID: sf.Meta.ID,
Name: sf.Meta.Name,
TrackID: sf.Meta.TrackID,
MaxCars: sf.Meta.MaxCars,
Laps: sf.Meta.Laps,
TimeLimitS: sf.Meta.TimeLimitS,
DriverIDs: append([]string(nil), sf.Meta.DriverIDs...),
Status: "finished",
CreatedMs: sf.Meta.CreatedMs,
StartedMs: startedMs,
FinishedMs: finishedMs,
DurationMs: finishedMs - startedMs,
TotalLaps: sf.TotalLaps,
TotalDrivers: len(sf.Meta.DriverIDs),
WinnerDriverID: sf.WinnerDriverID,
WinnerName: sf.WinnerName,
BestLapMs: sf.BestLapMs,
ID: sf.Meta.ID,
Name: sf.Meta.Name,
TrackID: sf.Meta.TrackID,
MaxCars: sf.Meta.MaxCars,
Laps: sf.Meta.Laps,
TimeLimitS: sf.Meta.TimeLimitS,
DriverIDs: append([]string(nil), sf.Meta.DriverIDs...),
Status: "finished",
CreatedMs: sf.Meta.CreatedMs,
StartedMs: startedMs,
FinishedMs: finishedMs,
DurationMs: finishedMs - startedMs,
TotalLaps: sf.TotalLaps,
TotalDrivers: len(sf.Meta.DriverIDs),
Results: results,
}
return s.pg.InsertFinished(ctx, r)
}
@@ -596,6 +675,11 @@ func (s *Service) ListPlans(ctx context.Context, cur Cursor, limit int) ([]RaceP
return s.pg.ListPlans(ctx, cur, limit)
}
// CountPlans returns the total number of race plans.
func (s *Service) CountPlans(ctx context.Context) (int, error) {
return s.pg.CountPlans(ctx)
}
// DeletePlan removes a plan by id.
func (s *Service) DeletePlan(ctx context.Context, id string) error {
return s.pg.DeletePlan(ctx, id)
+212 -62
View File
@@ -2,9 +2,9 @@ package races
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -24,23 +24,44 @@ type PodiumEntry struct {
TotalTimeMs int64 `json:"total_time_ms" example:"123456"`
}
// DriverResult is the per-driver outcome of a race. Stored in
// race_drivers. DriverID + TotalTimeMs are required; BestLapMs is
// the driver's fastest lap; Position is 1..N when the driver
// finished, or NULL when DNF/DSQ. The aggregate podium and winner
// fields are derived from this row set on read.
type DriverResult struct {
DriverID string
TotalTimeMs int64
BestLapMs int64
Position *int // nil for DNF
}
// FinishedRace is the on-disk shape of a completed race. Mirrors the
// lobby.RaceMeta plus outcome fields populated by the engine at finish.
// lobby.RaceMeta plus aggregate outcome fields. Per-driver results
// (including the podium) live in race_drivers and are read into
// Results on demand.
type FinishedRace struct {
ID string
Name string
TrackID string
MaxCars int
Laps int
TimeLimitS int
DriverIDs []string
Status string // "finished" | "cancelled"
CreatedMs int64
StartedMs int64
FinishedMs int64
DurationMs int64
TotalLaps int
TotalDrivers int
ID string
Name string
TrackID string
MaxCars int
Laps int
TimeLimitS int
DriverIDs []string
Status string // "finished" | "cancelled"
CreatedMs int64
StartedMs int64
FinishedMs int64
DurationMs int64
TotalLaps int
TotalDrivers int
// Results is the per-driver race_drivers data, hydrated on read.
// Includes the winner and the podium.
Results []DriverResult
// Derived fields (computed in scanFinished from Results). The
// public API exposes them so existing clients keep working.
WinnerDriverID string
WinnerName string
BestLapMs int64
@@ -126,8 +147,8 @@ func (s *PgStore) UpsertLive(ctx context.Context, r lobby.RaceMeta) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO races
(id, name, track_id, max_cars, laps, time_limit_s, status,
created_ms, started_ms, finished_ms, updated_ms, podium)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, $10, '[]'::jsonb)
created_ms, started_ms, finished_ms, updated_ms)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, $10)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
track_id = EXCLUDED.track_id,
@@ -363,8 +384,8 @@ func (s *PgStore) UpsertLobbyDriver(ctx context.Context, d lobby.DriverMeta) err
_, err := s.pool.Exec(ctx, `
INSERT INTO lobby_drivers
(driver_id, name, nickname, avatar_url, clan_id, clan_tag,
status, current_race_id, connected_ms, last_seen_ms)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
status, current_race_id, connected_ms, last_seen_ms, device_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (driver_id) DO UPDATE SET
name = EXCLUDED.name,
nickname = EXCLUDED.nickname,
@@ -373,9 +394,10 @@ func (s *PgStore) UpsertLobbyDriver(ctx context.Context, d lobby.DriverMeta) err
clan_tag = EXCLUDED.clan_tag,
status = EXCLUDED.status,
current_race_id = EXCLUDED.current_race_id,
last_seen_ms = EXCLUDED.last_seen_ms`,
last_seen_ms = EXCLUDED.last_seen_ms,
device_id = EXCLUDED.device_id`,
d.ID, d.Name, d.Nickname, d.AvatarURL, d.ClanID, d.ClanTag,
string(d.Status), d.RaceID, d.ConnectedMs, d.LastSeenMs)
string(d.Status), d.RaceID, d.ConnectedMs, d.LastSeenMs, d.DeviceID)
if err != nil {
return fmt.Errorf("upsert lobby driver %s: %w", d.ID, err)
}
@@ -392,7 +414,7 @@ func (s *PgStore) DeleteLobbyDriver(ctx context.Context, driverID string) error
func (s *PgStore) ListLobbyDrivers(ctx context.Context) ([]lobby.DriverMeta, error) {
rows, err := s.pool.Query(ctx, `
SELECT driver_id, name, nickname, avatar_url, clan_id, clan_tag,
status, current_race_id, connected_ms, last_seen_ms
status, current_race_id, connected_ms, last_seen_ms, device_id
FROM lobby_drivers ORDER BY last_seen_ms DESC`)
if err != nil {
return nil, err
@@ -403,7 +425,7 @@ func (s *PgStore) ListLobbyDrivers(ctx context.Context) ([]lobby.DriverMeta, err
var d lobby.DriverMeta
var status string
if err := rows.Scan(&d.ID, &d.Name, &d.Nickname, &d.AvatarURL, &d.ClanID, &d.ClanTag,
&status, &d.RaceID, &d.ConnectedMs, &d.LastSeenMs); err != nil {
&status, &d.RaceID, &d.ConnectedMs, &d.LastSeenMs, &d.DeviceID); err != nil {
return nil, err
}
d.Status = lobby.DriverStatus(status)
@@ -416,41 +438,66 @@ func (s *PgStore) ListLobbyDrivers(ctx context.Context) ([]lobby.DriverMeta, err
// finished_races
// ---------------------------------------------------------------------------
// InsertFinished upserts a finished race row.
// InsertFinished upserts a finished race row and replaces its
// race_drivers entries in a single transaction. Per-driver outcome
// (total_time_ms, best_lap_ms, position) lives on race_drivers;
// the race row itself carries only metadata.
func (s *PgStore) InsertFinished(ctx context.Context, r FinishedRace) error {
podium := r.Podium
if podium == nil {
podium = []PodiumEntry{}
}
podiumJSON, err := json.Marshal(podium)
if err != nil {
return fmt.Errorf("marshal podium: %w", err)
}
_, err = s.pool.Exec(ctx, `
_, err := s.pool.Exec(ctx, `
INSERT INTO races
(id, name, track_id, max_cars, laps, time_limit_s,
driver_ids, status, created_ms, started_ms, finished_ms, duration_ms,
total_laps, total_drivers, winner_driver_id, winner_name, best_lap_ms,
podium)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
status, created_ms, started_ms, finished_ms, duration_ms,
total_laps, total_drivers)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
status = EXCLUDED.status,
finished_ms = EXCLUDED.finished_ms,
duration_ms = EXCLUDED.duration_ms,
total_laps = EXCLUDED.total_laps,
total_drivers = EXCLUDED.total_drivers,
winner_driver_id = EXCLUDED.winner_driver_id,
winner_name = EXCLUDED.winner_name,
best_lap_ms = EXCLUDED.best_lap_ms,
podium = EXCLUDED.podium`,
total_drivers = EXCLUDED.total_drivers`,
r.ID, r.Name, r.TrackID, r.MaxCars, r.Laps, r.TimeLimitS,
r.DriverIDs, r.Status, r.CreatedMs, r.StartedMs, r.FinishedMs, r.DurationMs,
r.TotalLaps, r.TotalDrivers, r.WinnerDriverID, r.WinnerName, r.BestLapMs,
podiumJSON)
r.Status, r.CreatedMs, r.StartedMs, r.FinishedMs, r.DurationMs,
r.TotalLaps, r.TotalDrivers)
if err != nil {
return fmt.Errorf("insert finished race %s: %w", r.ID, err)
}
// Replace race_drivers with the provided list atomically.
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `DELETE FROM race_drivers WHERE race_id = $1`, r.ID); err != nil {
return fmt.Errorf("clear race_drivers for %s: %w", r.ID, err)
}
// Build a map from driver_id -> DriverResult for lookup.
resByDriver := make(map[string]DriverResult, len(r.Results))
for _, dr := range r.Results {
resByDriver[dr.DriverID] = dr
}
for i, did := range r.DriverIDs {
dr, ok := resByDriver[did]
_ = ok // missing result: total_time_ms=0, best_lap_ms=0, position=NULL
var totalTime, bestLap int64
var pos *int
if ok {
totalTime = dr.TotalTimeMs
bestLap = dr.BestLapMs
pos = dr.Position
}
if _, err := tx.Exec(ctx, `
INSERT INTO race_drivers
(race_id, driver_id, slot, joined_ms,
total_time_ms, best_lap_ms, position)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
r.ID, did, i, r.CreatedMs, totalTime, bestLap, pos); err != nil {
return fmt.Errorf("insert race_drivers %s: %w", r.ID, err)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit race_drivers: %w", err)
}
return nil
}
@@ -474,9 +521,8 @@ func (s *PgStore) ListFinished(ctx context.Context, trackID string, cur Cursor,
}
args = append(args, limit+1)
q := `SELECT id, name, track_id, max_cars, laps, time_limit_s,
driver_ids, status, created_ms, started_ms, finished_ms, duration_ms,
total_laps, total_drivers, winner_driver_id, winner_name, best_lap_ms,
podium
status, created_ms, started_ms, finished_ms, duration_ms,
total_laps, total_drivers
FROM races
WHERE ` + joinAnd(conds) + `
ORDER BY finished_ms DESC, id DESC
@@ -486,36 +532,130 @@ func (s *PgStore) ListFinished(ctx context.Context, trackID string, cur Cursor,
return nil, fmt.Errorf("list finished: %w", err)
}
defer rows.Close()
return scanFinished(rows)
return s.scanFinished(ctx, rows)
}
func scanFinished(rows pgx.Rows) ([]FinishedRace, error) {
// CountRaces returns the total number of races matching the status filter and trackID.
func (s *PgStore) CountRaces(ctx context.Context, statuses []string, trackID string) (int, error) {
args := []any{}
conds := []string{"1=1"}
if len(statuses) > 0 {
placeholders := make([]string, len(statuses))
for i, st := range statuses {
args = append(args, st)
placeholders[i] = fmt.Sprintf("$%d", len(args))
}
conds = append(conds, fmt.Sprintf("status IN (%s)", strings.Join(placeholders, ",")))
}
if trackID != "" {
args = append(args, trackID)
conds = append(conds, fmt.Sprintf("track_id = $%d", len(args)))
}
q := `SELECT COUNT(*) FROM races WHERE ` + joinAnd(conds)
var count int
err := s.pool.QueryRow(ctx, q, args...).Scan(&count)
if err != nil {
return 0, fmt.Errorf("count races: %w", err)
}
return count, nil
}
// scanFinished reads the rows from the SELECT above, then hydrates
// DriverIDs and the per-driver Results from race_drivers. The
// Winner* and Podium fields are derived from Results.
func (s *PgStore) scanFinished(ctx context.Context, rows pgx.Rows) ([]FinishedRace, error) {
out := make([]FinishedRace, 0)
for rows.Next() {
var r FinishedRace
var podiumJSON []byte
if err := rows.Scan(
&r.ID, &r.Name, &r.TrackID,
&r.MaxCars, &r.Laps, &r.TimeLimitS, &r.DriverIDs, &r.Status,
&r.MaxCars, &r.Laps, &r.TimeLimitS, &r.Status,
&r.CreatedMs, &r.StartedMs, &r.FinishedMs, &r.DurationMs,
&r.TotalLaps, &r.TotalDrivers, &r.WinnerDriverID, &r.WinnerName, &r.BestLapMs,
&podiumJSON,
&r.TotalLaps, &r.TotalDrivers,
); err != nil {
return nil, err
}
if len(podiumJSON) > 0 {
if err := json.Unmarshal(podiumJSON, &r.Podium); err != nil {
return nil, fmt.Errorf("unmarshal podium: %w", err)
}
}
if r.Podium == nil {
r.Podium = []PodiumEntry{}
if err := s.hydrateFinished(ctx, &r); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// hydrateFinished pulls race_drivers for the given race into Results
// and derives the Winner* and Podium fields.
func (s *PgStore) hydrateFinished(ctx context.Context, r *FinishedRace) error {
rows, err := s.pool.Query(ctx, `
SELECT driver_id, total_time_ms, best_lap_ms, position
FROM race_drivers
WHERE race_id = $1
ORDER BY slot ASC, joined_ms ASC`, r.ID)
if err != nil {
return fmt.Errorf("hydrate race_drivers %s: %w", r.ID, err)
}
defer rows.Close()
r.Results = make([]DriverResult, 0)
r.DriverIDs = make([]string, 0)
r.Podium = make([]PodiumEntry, 0)
for rows.Next() {
var dr DriverResult
var pos *int
if err := rows.Scan(&dr.DriverID, &dr.TotalTimeMs, &dr.BestLapMs, &pos); err != nil {
return err
}
dr.Position = pos
r.Results = append(r.Results, dr)
r.DriverIDs = append(r.DriverIDs, dr.DriverID)
}
if err := rows.Err(); err != nil {
return err
}
// Derive winner (lowest total_time_ms among rows with time > 0).
var bestTotal int64 = -1
for _, dr := range r.Results {
if dr.TotalTimeMs <= 0 {
continue
}
if bestTotal == -1 || dr.TotalTimeMs < bestTotal {
bestTotal = dr.TotalTimeMs
r.WinnerDriverID = dr.DriverID
r.WinnerName = dr.DriverID
}
if dr.BestLapMs > 0 && (r.BestLapMs == 0 || dr.BestLapMs < r.BestLapMs) {
r.BestLapMs = dr.BestLapMs
}
}
// Derive podium: top-3 drivers by total_time_ms (> 0) ASC.
type row struct {
id string
t int64
best int64
}
var sorted []row
for _, dr := range r.Results {
if dr.TotalTimeMs <= 0 {
continue
}
sorted = append(sorted, row{dr.DriverID, dr.TotalTimeMs, dr.BestLapMs})
}
for i := 1; i < len(sorted); i++ {
for j := i; j > 0 && sorted[j-1].t > sorted[j].t; j-- {
sorted[j-1], sorted[j] = sorted[j], sorted[j-1]
}
}
for i := 0; i < len(sorted) && i < 3; i++ {
r.Podium = append(r.Podium, PodiumEntry{
Position: i + 1,
DriverID: sorted[i].id,
Name: sorted[i].id,
TotalTimeMs: sorted[i].t,
})
}
return nil
}
func joinAnd(parts []string) string {
out := ""
for i, p := range parts {
@@ -624,6 +764,16 @@ func scanPlans(rows pgx.Rows) ([]RacePlan, error) {
return out, rows.Err()
}
// CountPlans returns the total number of race plans.
func (s *PgStore) CountPlans(ctx context.Context) (int, error) {
var count int
err := s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM race_plans").Scan(&count)
if err != nil {
return 0, fmt.Errorf("count plans: %w", err)
}
return count, nil
}
// DeletePlan removes a plan by id.
func (s *PgStore) DeletePlan(ctx context.Context, id string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM race_plans WHERE id = $1`, id)