mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
Add leaderboard for all tracks and track calendar
This commit is contained in:
@@ -606,4 +606,45 @@ func (s *Service) ListCarsByOwner(ownerID string) []CarMeta {
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetTrackCalendar returns all track calendar entries from the store.
|
||||
func (s *Service) GetTrackCalendar(ctx context.Context) ([]TrackCalendarEntry, error) {
|
||||
if s.store == nil {
|
||||
return nil, ErrStoreMissing
|
||||
}
|
||||
return s.store.GetTrackCalendar(ctx)
|
||||
}
|
||||
|
||||
// CreateTrackCalendar creates a new calendar entry.
|
||||
func (s *Service) CreateTrackCalendar(ctx context.Context, trackID string, startAt, endAt time.Time) (TrackCalendarEntry, error) {
|
||||
if s.store == nil {
|
||||
return TrackCalendarEntry{}, ErrStoreMissing
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
_, trackExists := s.tracks[TrackID(trackID)]
|
||||
s.mu.RUnlock()
|
||||
if !trackExists {
|
||||
return TrackCalendarEntry{}, fmt.Errorf("%w: track %s does not exist", ErrNotFound, trackID)
|
||||
}
|
||||
|
||||
if startAt.After(endAt) || startAt.Equal(endAt) {
|
||||
return TrackCalendarEntry{}, fmt.Errorf("%w: start time must be before end time", ErrInvalidInput)
|
||||
}
|
||||
|
||||
entry := TrackCalendarEntry{
|
||||
TrackID: trackID,
|
||||
StartAt: startAt,
|
||||
EndAt: endAt,
|
||||
}
|
||||
return s.store.CreateTrackCalendar(ctx, entry)
|
||||
}
|
||||
|
||||
// DeleteTrackCalendar deletes a calendar entry.
|
||||
func (s *Service) DeleteTrackCalendar(ctx context.Context, id int) error {
|
||||
if s.store == nil {
|
||||
return ErrStoreMissing
|
||||
}
|
||||
return s.store.DeleteTrackCalendar(ctx, id)
|
||||
}
|
||||
@@ -398,4 +398,54 @@ func TestServiceWithoutStore(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error when store is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackCalendar(t *testing.T) {
|
||||
s, _ := loadSeeds()
|
||||
defer s.Stop()
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. Initially calendar should be empty
|
||||
entries, err := s.GetTrackCalendar(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("get calendar: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("expected 0 entries, got %d", len(entries))
|
||||
}
|
||||
|
||||
// 2. Schedule a track
|
||||
start := time.Now().Add(-1 * time.Hour)
|
||||
end := time.Now().Add(1 * time.Hour)
|
||||
created, err := s.CreateTrackCalendar(ctx, "monaco", start, end)
|
||||
if err != nil {
|
||||
t.Fatalf("create calendar: %v", err)
|
||||
}
|
||||
if created.TrackID != "monaco" || !created.StartAt.Equal(start) || !created.EndAt.Equal(end) {
|
||||
t.Errorf("invalid created entry: %+v", created)
|
||||
}
|
||||
|
||||
// 3. Get calendar should return the entry
|
||||
entries, err = s.GetTrackCalendar(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("get calendar: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].ID != created.ID {
|
||||
t.Errorf("expected 1 entry with id %d, got %v", created.ID, entries)
|
||||
}
|
||||
|
||||
// 4. Delete entry
|
||||
err = s.DeleteTrackCalendar(ctx, created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("delete calendar: %v", err)
|
||||
}
|
||||
|
||||
// 5. Get calendar should be empty again
|
||||
entries, err = s.GetTrackCalendar(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("get calendar: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("expected 0 entries, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
@@ -27,4 +27,9 @@ type Store interface {
|
||||
// UpdateCarStats applies a partial mutation of read-mostly stats
|
||||
// fields on a car. Used by the race engine hooks.
|
||||
UpdateCarStats(ctx context.Context, id string, fn func(*CarMeta)) error
|
||||
|
||||
// Track Calendar methods
|
||||
GetTrackCalendar(ctx context.Context) ([]TrackCalendarEntry, error)
|
||||
CreateTrackCalendar(ctx context.Context, entry TrackCalendarEntry) (TrackCalendarEntry, error)
|
||||
DeleteTrackCalendar(ctx context.Context, id int) error
|
||||
}
|
||||
@@ -8,15 +8,18 @@ import (
|
||||
// memoryStore is an in-process implementation of Store for tests and
|
||||
// for early PoC runs without a database. It is goroutine-safe.
|
||||
type memoryStore struct {
|
||||
mu sync.Mutex
|
||||
tracks map[TrackID]TrackMeta
|
||||
cars map[CarID]CarMeta
|
||||
mu sync.Mutex
|
||||
tracks map[TrackID]TrackMeta
|
||||
cars map[CarID]CarMeta
|
||||
calendar []TrackCalendarEntry
|
||||
nextID int
|
||||
}
|
||||
|
||||
func newMemoryStore() *memoryStore {
|
||||
return &memoryStore{
|
||||
tracks: make(map[TrackID]TrackMeta),
|
||||
cars: make(map[CarID]CarMeta),
|
||||
nextID: 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,4 +75,32 @@ func (m *memoryStore) UpdateCarStats(_ context.Context, id string, fn func(*CarM
|
||||
fn(&c)
|
||||
m.cars[id] = c
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) GetTrackCalendar(_ context.Context) ([]TrackCalendarEntry, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := append([]TrackCalendarEntry(nil), m.calendar...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) CreateTrackCalendar(_ context.Context, entry TrackCalendarEntry) (TrackCalendarEntry, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
entry.ID = m.nextID
|
||||
m.nextID++
|
||||
m.calendar = append(m.calendar, entry)
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) DeleteTrackCalendar(_ context.Context, id int) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for i, entry := range m.calendar {
|
||||
if entry.ID == id {
|
||||
m.calendar = append(m.calendar[:i], m.calendar[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return ErrNotFound
|
||||
}
|
||||
@@ -94,6 +94,14 @@ type TrackMeta struct {
|
||||
UpdatedMs int64 `json:"updated_ms"`
|
||||
}
|
||||
|
||||
// TrackCalendarEntry describes one active period for a track.
|
||||
type TrackCalendarEntry struct {
|
||||
ID int `json:"id"`
|
||||
TrackID string `json:"track_id"`
|
||||
StartAt time.Time `json:"start_at"`
|
||||
EndAt time.Time `json:"end_at"`
|
||||
}
|
||||
|
||||
// MotorSpec describes the electric motor.
|
||||
type MotorSpec struct {
|
||||
Kind string `json:"kind"` // brushed | brushless
|
||||
|
||||
@@ -451,3 +451,47 @@ func (s *PgStore) GetDriverLeaderboardProfile(ctx context.Context, driverID stri
|
||||
}
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
// TrackCalendarInfo holds the basic details of a scheduled track.
|
||||
type TrackCalendarInfo struct {
|
||||
ID int
|
||||
TrackID string
|
||||
TrackName string
|
||||
StartAt time.Time
|
||||
EndAt time.Time
|
||||
}
|
||||
|
||||
// TrackCalendarLeaderboard holds the leaderboard summary for a track calendar event.
|
||||
type TrackCalendarLeaderboard struct {
|
||||
TrackID string
|
||||
TrackName string
|
||||
StartAt time.Time
|
||||
EndAt time.Time
|
||||
Status string // finished, active, planned
|
||||
Podium []LeaderboardEntry
|
||||
CurrentDriver *LeaderboardEntry
|
||||
}
|
||||
|
||||
// GetCalendarLeaderboardTracks returns all track calendar entries joined with their track names, sorted chronologically.
|
||||
func (s *PgStore) GetCalendarLeaderboardTracks(ctx context.Context) ([]TrackCalendarInfo, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT tc.id, tc.track_id, t.name, tc.start_at, tc.end_at
|
||||
FROM track_calendar tc
|
||||
JOIN tracks t ON tc.track_id = t.id
|
||||
ORDER BY tc.start_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query calendar tracks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []TrackCalendarInfo
|
||||
for rows.Next() {
|
||||
var info TrackCalendarInfo
|
||||
if err := rows.Scan(&info.ID, &info.TrackID, &info.TrackName, &info.StartAt, &info.EndAt); err != nil {
|
||||
return nil, fmt.Errorf("scan calendar track: %w", err)
|
||||
}
|
||||
out = append(out, info)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -259,4 +259,52 @@ func TestLeaderboard(t *testing.T) {
|
||||
t.Errorf("expected Driver One, DRV, TST, got %+v", entry)
|
||||
}
|
||||
})
|
||||
|
||||
// Test Calendar Leaderboard
|
||||
t.Run("Calendar Leaderboard", func(t *testing.T) {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM track_calendar")
|
||||
|
||||
now := time.Now()
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO track_calendar (track_id, start_at, end_at) VALUES
|
||||
('monaco', $1, $2),
|
||||
('barcelona-catalunya', $3, $4),
|
||||
('austria-redbull-ring', $5, $6)
|
||||
`,
|
||||
now.Add(-10*time.Hour), now.Add(-5*time.Hour),
|
||||
now.Add(-2*time.Hour), now.Add(2*time.Hour),
|
||||
now.Add(5*time.Hour), now.Add(10*time.Hour),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert track calendar: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM track_calendar")
|
||||
}()
|
||||
|
||||
svc := NewService(store, nil)
|
||||
svc.now = func() time.Time { return now }
|
||||
|
||||
res, err := svc.GetCalendarLeaderboard(ctx, currentYear, "test-driver-1")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get calendar leaderboard: %v", err)
|
||||
}
|
||||
|
||||
if len(res) != 3 {
|
||||
t.Fatalf("expected 3 calendar entries, got %d", len(res))
|
||||
}
|
||||
|
||||
// 1. Monaco (finished)
|
||||
if res[0].TrackID != "monaco" || res[0].Status != "finished" {
|
||||
t.Errorf("expected monaco (finished), got %s (%s)", res[0].TrackID, res[0].Status)
|
||||
}
|
||||
// 2. Barcelona (active)
|
||||
if res[1].TrackID != "barcelona-catalunya" || res[1].Status != "active" {
|
||||
t.Errorf("expected barcelona-catalunya (active), got %s (%s)", res[1].TrackID, res[1].Status)
|
||||
}
|
||||
// 3. Austria (planned)
|
||||
if res[2].TrackID != "austria-redbull-ring" || res[2].Status != "planned" {
|
||||
t.Errorf("expected austria-redbull-ring (planned), got %s (%s)", res[2].TrackID, res[2].Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -236,10 +236,54 @@ func (r *Runner) Run(ctx context.Context, opt Options) (Summary, error) {
|
||||
s.PlansInserted++
|
||||
}
|
||||
|
||||
if err := r.seedTrackCalendar(ctx); err != nil {
|
||||
return s, fmt.Errorf("seed track calendar: %w", err)
|
||||
}
|
||||
|
||||
s.Duration = time.Since(start)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (r *Runner) seedTrackCalendar(ctx context.Context) error {
|
||||
now := r.now
|
||||
|
||||
if len(r.trackIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tracksMap := make(map[string]bool)
|
||||
for _, id := range r.trackIDs {
|
||||
tracksMap[id] = true
|
||||
}
|
||||
|
||||
schedule := []struct {
|
||||
trackID string
|
||||
startAt time.Time
|
||||
endAt time.Time
|
||||
}{
|
||||
{"monaco", now.Add(-14 * 24 * time.Hour), now.Add(-7 * 24 * time.Hour)},
|
||||
{"barcelona-catalunya", now.Add(-2 * 24 * time.Hour), now.Add(12 * 24 * time.Hour)},
|
||||
{"austria-redbull-ring", now.Add(14 * 24 * time.Hour), now.Add(28 * 24 * time.Hour)},
|
||||
{"great-britain-silverstone", now.Add(28 * 24 * time.Hour), now.Add(42 * 24 * time.Hour)},
|
||||
{"belgium-spa", now.Add(42 * 24 * time.Hour), now.Add(56 * 24 * time.Hour)},
|
||||
}
|
||||
|
||||
for _, s := range schedule {
|
||||
if !tracksMap[s.trackID] {
|
||||
continue
|
||||
}
|
||||
_, err := r.pg.Exec(ctx, `
|
||||
INSERT INTO track_calendar (track_id, start_at, end_at)
|
||||
VALUES ($1, $2, $3)
|
||||
`, s.trackID, s.startAt, s.endAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert calendar entry: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type planShape struct {
|
||||
intervalS int
|
||||
count int
|
||||
@@ -251,18 +295,31 @@ type planShape struct {
|
||||
// cascade), plus the plan and queue tables. Drivers and clans are
|
||||
// kept (they're independent of races).
|
||||
func (r *Runner) resetAll(ctx context.Context) error {
|
||||
if _, err := r.pg.Exec(ctx,
|
||||
`DELETE FROM races WHERE status IN ('finished','cancelled')`); err != nil {
|
||||
if _, err := r.pg.Exec(ctx, `DELETE FROM race_queue`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.pg.Exec(ctx,
|
||||
`TRUNCATE TABLE race_plans, race_queue, drivers, clans CASCADE`); err != nil {
|
||||
if _, err := r.pg.Exec(ctx, `DELETE FROM races`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.pg.Exec(ctx, `DELETE FROM race_plans`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.pg.Exec(ctx, `DELETE FROM track_calendar`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.pg.Exec(ctx, `DELETE FROM drivers`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.pg.Exec(ctx, `DELETE FROM clans`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.pg.Exec(ctx, `ALTER SEQUENCE IF EXISTS track_calendar_id_seq RESTART WITH 1`); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range r.lb.ListRaces() {
|
||||
_ = r.lb.DeleteRace(m.ID)
|
||||
}
|
||||
if _, err := r.pg.Exec(ctx, `TRUNCATE TABLE lobby_drivers`); err != nil {
|
||||
if _, err := r.pg.Exec(ctx, `DELETE FROM lobby_drivers`); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -740,6 +740,78 @@ func (s *Service) GetLeaderboard(ctx context.Context, trackID string, year int,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCalendarLeaderboard returns the leaderboard details of all scheduled tracks.
|
||||
func (s *Service) GetCalendarLeaderboard(ctx context.Context, year int, currentDriverID string) ([]TrackCalendarLeaderboard, error) {
|
||||
if year == 0 {
|
||||
year = s.now().Year()
|
||||
}
|
||||
|
||||
calendarInfo, err := s.pg.GetCalendarLeaderboardTracks(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := s.now()
|
||||
var out []TrackCalendarLeaderboard
|
||||
|
||||
// Cache driver profile fallback if we need it
|
||||
var fallbackProfile *LeaderboardEntry
|
||||
if currentDriverID != "" {
|
||||
fallbackProfile, err = s.pg.GetDriverLeaderboardProfile(ctx, currentDriverID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if fallbackProfile != nil {
|
||||
fallbackProfile.Points = 0
|
||||
fallbackProfile.Rank = 0
|
||||
}
|
||||
}
|
||||
|
||||
for _, cal := range calendarInfo {
|
||||
status := "planned"
|
||||
if now.After(cal.EndAt) {
|
||||
status = "finished"
|
||||
} else if (now.Equal(cal.StartAt) || now.After(cal.StartAt)) && now.Before(cal.EndAt) {
|
||||
status = "active"
|
||||
}
|
||||
|
||||
// Get top 3
|
||||
podium, _, err := s.pg.GetTrackLeaderboard(ctx, cal.TrackID, year, 3, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if podium == nil {
|
||||
podium = []LeaderboardEntry{}
|
||||
}
|
||||
|
||||
// Get current driver
|
||||
var curDriver *LeaderboardEntry
|
||||
if currentDriverID != "" {
|
||||
curDriver, err = s.pg.GetTrackLeaderboardDriver(ctx, cal.TrackID, year, currentDriverID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if curDriver == nil && fallbackProfile != nil {
|
||||
// Clone the fallback profile so we don't share pointer/modify original
|
||||
profileCopy := *fallbackProfile
|
||||
curDriver = &profileCopy
|
||||
}
|
||||
}
|
||||
|
||||
out = append(out, TrackCalendarLeaderboard{
|
||||
TrackID: cal.TrackID,
|
||||
TrackName: cal.TrackName,
|
||||
StartAt: cal.StartAt,
|
||||
EndAt: cal.EndAt,
|
||||
Status: status,
|
||||
Podium: podium,
|
||||
CurrentDriver: curDriver,
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheduler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -112,8 +112,8 @@ func NewPgStore(pool *pgxpool.Pool) *PgStore {
|
||||
}
|
||||
|
||||
// Exec exposes a raw Exec for the seeder / maintenance scripts.
|
||||
func (s *PgStore) Exec(ctx context.Context, sql string) (int64, error) {
|
||||
tag, err := s.pool.Exec(ctx, sql)
|
||||
func (s *PgStore) Exec(ctx context.Context, sql string, args ...any) (int64, error) {
|
||||
tag, err := s.pool.Exec(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 014_track_calendar.sql — schema for F1-style active track scheduling.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS track_calendar (
|
||||
id SERIAL PRIMARY KEY,
|
||||
track_id TEXT NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
start_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
end_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
CHECK (start_at < end_at)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS track_calendar_dates_idx ON track_calendar (start_at, end_at);
|
||||
@@ -370,4 +370,52 @@ func (s *PgStore) UpdateCarStats(ctx context.Context, id string, fn func(*catalo
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// GetTrackCalendar returns all track calendar entries ordered by start_at.
|
||||
func (s *PgStore) GetTrackCalendar(ctx context.Context) ([]catalog.TrackCalendarEntry, error) {
|
||||
query := `SELECT id, track_id, start_at, end_at FROM track_calendar ORDER BY start_at ASC`
|
||||
rows, err := s.pool.Query(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []catalog.TrackCalendarEntry
|
||||
for rows.Next() {
|
||||
var entry catalog.TrackCalendarEntry
|
||||
if err := rows.Scan(&entry.ID, &entry.TrackID, &entry.StartAt, &entry.EndAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// CreateTrackCalendar inserts a new calendar entry.
|
||||
func (s *PgStore) CreateTrackCalendar(ctx context.Context, entry catalog.TrackCalendarEntry) (catalog.TrackCalendarEntry, error) {
|
||||
query := `
|
||||
INSERT INTO track_calendar (track_id, start_at, end_at)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, track_id, start_at, end_at`
|
||||
|
||||
row := s.pool.QueryRow(ctx, query, entry.TrackID, entry.StartAt, entry.EndAt)
|
||||
var res catalog.TrackCalendarEntry
|
||||
if err := row.Scan(&res.ID, &res.TrackID, &res.StartAt, &res.EndAt); err != nil {
|
||||
return catalog.TrackCalendarEntry{}, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// DeleteTrackCalendar deletes a calendar entry by ID.
|
||||
func (s *PgStore) DeleteTrackCalendar(ctx context.Context, id int) error {
|
||||
command := `DELETE FROM track_calendar WHERE id = $1`
|
||||
res, err := s.pool.Exec(ctx, command, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.RowsAffected() == 0 {
|
||||
return catalog.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -362,6 +362,7 @@ type TrackWire struct {
|
||||
BestLapHolder string `json:"best_lap_holder,omitempty"`
|
||||
CreatedMs int64 `json:"created_ms"`
|
||||
UpdatedMs int64 `json:"updated_ms"`
|
||||
IsActive bool `json:"is_active" example:"true"`
|
||||
}
|
||||
|
||||
// TrackListRequest: payload { "scope": "all|system|public|private", "tag": "..." }.
|
||||
@@ -862,6 +863,42 @@ type LeaderboardResponse struct {
|
||||
CurrentDriver *LeaderboardEntry `json:"current_driver,omitempty"`
|
||||
}
|
||||
|
||||
// TrackCalendarEntry is the wire representation of an active track schedule period.
|
||||
type TrackCalendarEntry struct {
|
||||
ID int `json:"id" example:"1"`
|
||||
TrackID string `json:"track_id" example:"monaco"`
|
||||
StartAt time.Time `json:"start_at" example:"2026-07-15T00:00:00Z"`
|
||||
EndAt time.Time `json:"end_at" example:"2026-07-30T00:00:00Z"`
|
||||
}
|
||||
|
||||
// TrackCalendarCreateRequest is the payload to schedule a track active period.
|
||||
type TrackCalendarCreateRequest struct {
|
||||
TrackID string `json:"track_id" example:"monaco"`
|
||||
StartAt time.Time `json:"start_at" example:"2026-07-15T00:00:00Z"`
|
||||
EndAt time.Time `json:"end_at" example:"2026-07-30T00:00:00Z"`
|
||||
}
|
||||
|
||||
// TrackCalendarResponse is the wrapper for the calendar list.
|
||||
type TrackCalendarResponse struct {
|
||||
Items []TrackCalendarEntry `json:"items"`
|
||||
}
|
||||
|
||||
// TrackCalendarLeaderboard holds the leaderboard summary for a track calendar event.
|
||||
type TrackCalendarLeaderboard struct {
|
||||
TrackID string `json:"track_id" example:"monaco"`
|
||||
TrackName string `json:"track_name" example:"Monaco"`
|
||||
StartAt time.Time `json:"start_at" example:"2026-07-15T00:00:00Z"`
|
||||
EndAt time.Time `json:"end_at" example:"2026-07-30T00:00:00Z"`
|
||||
Status string `json:"status" example:"active"`
|
||||
Podium []LeaderboardEntry `json:"podium"`
|
||||
CurrentDriver *LeaderboardEntry `json:"current_driver,omitempty"`
|
||||
}
|
||||
|
||||
// TrackCalendarLeaderboardResponse is the list response for calendar leaderboards.
|
||||
type TrackCalendarLeaderboardResponse struct {
|
||||
Items []TrackCalendarLeaderboard `json:"items"`
|
||||
}
|
||||
|
||||
// Encode marshals an envelope to JSON bytes.
|
||||
func Encode(env *Envelope) ([]byte, error) {
|
||||
if env.TSMs == 0 {
|
||||
|
||||
Reference in New Issue
Block a user