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:
@@ -17,9 +17,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -73,7 +75,7 @@ func boundsToWire(b catalog.Bounds) transport.TrackBounds {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func trackToWire(t catalog.TrackMeta) transport.TrackWire {
|
func trackToWire(t catalog.TrackMeta, activeTracks map[string]bool) transport.TrackWire {
|
||||||
return transport.TrackWire{
|
return transport.TrackWire{
|
||||||
ID: t.ID,
|
ID: t.ID,
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
@@ -92,20 +94,36 @@ func trackToWire(t catalog.TrackMeta) transport.TrackWire {
|
|||||||
BestLapHolder: t.BestLapHolder,
|
BestLapHolder: t.BestLapHolder,
|
||||||
CreatedMs: t.CreatedMs,
|
CreatedMs: t.CreatedMs,
|
||||||
UpdatedMs: t.UpdatedMs,
|
UpdatedMs: t.UpdatedMs,
|
||||||
|
IsActive: activeTracks[t.ID],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func tracksToWire(in []catalog.TrackMeta) []transport.TrackWire {
|
func tracksToWire(in []catalog.TrackMeta, activeTracks map[string]bool) []transport.TrackWire {
|
||||||
if len(in) == 0 {
|
if len(in) == 0 {
|
||||||
return []transport.TrackWire{}
|
return []transport.TrackWire{}
|
||||||
}
|
}
|
||||||
out := make([]transport.TrackWire, len(in))
|
out := make([]transport.TrackWire, len(in))
|
||||||
for i, t := range in {
|
for i, t := range in {
|
||||||
out[i] = trackToWire(t)
|
out[i] = trackToWire(t, activeTracks)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getActiveTracks(ctx context.Context, cat *catalog.Service) map[string]bool {
|
||||||
|
active := make(map[string]bool)
|
||||||
|
calendar, err := cat.GetTrackCalendar(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return active
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
for _, entry := range calendar {
|
||||||
|
if (now.Equal(entry.StartAt) || now.After(entry.StartAt)) && now.Before(entry.EndAt) {
|
||||||
|
active[entry.TrackID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return active
|
||||||
|
}
|
||||||
|
|
||||||
func trackFromWire(w transport.TrackWire) catalog.TrackMeta {
|
func trackFromWire(w transport.TrackWire) catalog.TrackMeta {
|
||||||
return catalog.TrackMeta{
|
return catalog.TrackMeta{
|
||||||
ID: w.ID,
|
ID: w.ID,
|
||||||
@@ -400,10 +418,11 @@ func mapCatalogError(err error) (int, string) {
|
|||||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||||
// @Router /api/catalog [get]
|
// @Router /api/catalog [get]
|
||||||
func catalogHandler(svc *catalog.Service) http.HandlerFunc {
|
func catalogHandler(svc *catalog.Service) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, _ *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
snap := svc.Snapshot()
|
snap := svc.Snapshot()
|
||||||
|
activeTracks := getActiveTracks(r.Context(), svc)
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"tracks": tracksToWire(snap.Tracks),
|
"tracks": tracksToWire(snap.Tracks, activeTracks),
|
||||||
"cars": carsToWire(snap.Cars),
|
"cars": carsToWire(snap.Cars),
|
||||||
"summary": summaryToWire(svc.Stats()),
|
"summary": summaryToWire(svc.Stats()),
|
||||||
})
|
})
|
||||||
@@ -439,8 +458,9 @@ func tracksHandler(svc *catalog.Service) http.HandlerFunc {
|
|||||||
if tag != "" {
|
if tag != "" {
|
||||||
out = filterTracksByTag(out, tag)
|
out = filterTracksByTag(out, tag)
|
||||||
}
|
}
|
||||||
|
activeTracks := getActiveTracks(r.Context(), svc)
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"tracks": tracksToWire(out),
|
"tracks": tracksToWire(out, activeTracks),
|
||||||
"count": len(out),
|
"count": len(out),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -471,6 +491,7 @@ func trackByIDHandler(svc *catalog.Service) http.HandlerFunc {
|
|||||||
writeError(w, http.StatusBadRequest, "bad_request", "missing track id")
|
writeError(w, http.StatusBadRequest, "bad_request", "missing track id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
activeTracks := getActiveTracks(r.Context(), svc)
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
t, ok := svc.GetTrack(id)
|
t, ok := svc.GetTrack(id)
|
||||||
@@ -478,7 +499,7 @@ func trackByIDHandler(svc *catalog.Service) http.HandlerFunc {
|
|||||||
writeError(w, http.StatusNotFound, "not_found", "track not found")
|
writeError(w, http.StatusNotFound, "not_found", "track not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, trackToWire(t))
|
writeJSON(w, http.StatusOK, trackToWire(t, activeTracks))
|
||||||
case http.MethodPut:
|
case http.MethodPut:
|
||||||
var req transport.TrackUpdateRequest
|
var req transport.TrackUpdateRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -495,7 +516,7 @@ func trackByIDHandler(svc *catalog.Service) http.HandlerFunc {
|
|||||||
writeError(w, status, code, err.Error())
|
writeError(w, status, code, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, transport.TrackAck{Ok: true, ID: updated.ID, Track: trackToWire(updated)})
|
writeJSON(w, http.StatusOK, transport.TrackAck{Ok: true, ID: updated.ID, Track: trackToWire(updated, activeTracks)})
|
||||||
case http.MethodDelete:
|
case http.MethodDelete:
|
||||||
if err := svc.DeleteTrack(r.Context(), id); err != nil {
|
if err := svc.DeleteTrack(r.Context(), id); err != nil {
|
||||||
status, code := mapCatalogError(err)
|
status, code := mapCatalogError(err)
|
||||||
@@ -549,7 +570,8 @@ func createTrackHandler(svc *catalog.Service) http.HandlerFunc {
|
|||||||
writeError(w, status, code, err.Error())
|
writeError(w, status, code, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, transport.TrackAck{Ok: true, ID: created.ID, Track: trackToWire(created)})
|
activeTracks := getActiveTracks(r.Context(), svc)
|
||||||
|
writeJSON(w, http.StatusCreated, transport.TrackAck{Ok: true, ID: created.ID, Track: trackToWire(created, activeTracks)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -734,3 +756,121 @@ func carsRouter(svc *catalog.Service) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// trackCalendarRouter routes /api/tracks/calendar to GET or POST
|
||||||
|
func trackCalendarRouter(cat *catalog.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
getTrackCalendarHandler(cat)(w, r)
|
||||||
|
case http.MethodPost:
|
||||||
|
createTrackCalendarHandler(cat)(w, r)
|
||||||
|
default:
|
||||||
|
w.Header().Set("Allow", "GET, POST")
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET or POST")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTrackCalendarHandler godoc
|
||||||
|
// @Summary Get Track Calendar
|
||||||
|
// @Description Returns the full calendar of track active periods.
|
||||||
|
// @Tags tracks
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} transport.TrackCalendarResponse "List of scheduled active periods"
|
||||||
|
// @Failure 500 {object} map[string]interface{} "Internal error"
|
||||||
|
// @Router /api/tracks/calendar [get]
|
||||||
|
func getTrackCalendarHandler(cat *catalog.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
entries, err := cat.GetTrackCalendar(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]transport.TrackCalendarEntry, len(entries))
|
||||||
|
for i, entry := range entries {
|
||||||
|
items[i] = transport.TrackCalendarEntry{
|
||||||
|
ID: entry.ID,
|
||||||
|
TrackID: entry.TrackID,
|
||||||
|
StartAt: entry.StartAt,
|
||||||
|
EndAt: entry.EndAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, transport.TrackCalendarResponse{Items: items})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTrackCalendarHandler godoc
|
||||||
|
// @Summary Schedule Track Active Period
|
||||||
|
// @Description Schedules an active period for a physical track.
|
||||||
|
// @Tags tracks
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param payload body transport.TrackCalendarCreateRequest true "Calendar entry details"
|
||||||
|
// @Success 201 {object} transport.TrackCalendarEntry "Created calendar entry"
|
||||||
|
// @Failure 400 {object} map[string]interface{} "Invalid input"
|
||||||
|
// @Failure 404 {object} map[string]interface{} "Track not found"
|
||||||
|
// @Failure 500 {object} map[string]interface{} "Internal error"
|
||||||
|
// @Router /api/tracks/calendar [post]
|
||||||
|
func createTrackCalendarHandler(cat *catalog.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req transport.TrackCalendarCreateRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := cat.CreateTrackCalendar(r.Context(), req.TrackID, req.StartAt, req.EndAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, catalog.ErrNotFound) {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, catalog.ErrInvalidInput) {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_input", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, transport.TrackCalendarEntry{
|
||||||
|
ID: entry.ID,
|
||||||
|
TrackID: entry.TrackID,
|
||||||
|
StartAt: entry.StartAt,
|
||||||
|
EndAt: entry.EndAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteTrackCalendarHandler godoc
|
||||||
|
// @Summary Delete Scheduled Period
|
||||||
|
// @Description Deletes a scheduled active period by ID.
|
||||||
|
// @Tags tracks
|
||||||
|
// @Param id path int true "Calendar Entry ID"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Failure 404 {object} map[string]interface{} "Not found"
|
||||||
|
// @Failure 500 {object} map[string]interface{} "Internal error"
|
||||||
|
// @Router /api/tracks/calendar/{id} [delete]
|
||||||
|
func deleteTrackCalendarHandler(cat *catalog.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := strings.TrimPrefix(r.URL.Path, "/api/tracks/calendar/")
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_id", "id must be an integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cat.DeleteTrackCalendar(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, catalog.ErrNotFound) {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -84,12 +84,13 @@ func (cb *catalogBroadcaster) broadcast(ev catalog.Event) {
|
|||||||
return
|
return
|
||||||
case catalog.EventTrackUpsert, catalog.EventTrackDelete:
|
case catalog.EventTrackUpsert, catalog.EventTrackDelete:
|
||||||
snap := cb.svc.Snapshot()
|
snap := cb.svc.Snapshot()
|
||||||
|
activeTracks := getActiveTracks(context.Background(), cb.svc)
|
||||||
cb.hub.Publish(&transport.Envelope{
|
cb.hub.Publish(&transport.Envelope{
|
||||||
Type: transport.TypeTrackSnapshot,
|
Type: transport.TypeTrackSnapshot,
|
||||||
Payload: transport.TrackSnapshot{
|
Payload: transport.TrackSnapshot{
|
||||||
GeneratedMs: snap.GeneratedMs,
|
GeneratedMs: snap.GeneratedMs,
|
||||||
Version: snap.Version,
|
Version: snap.Version,
|
||||||
Tracks: tracksToWire(snap.Tracks),
|
Tracks: tracksToWire(snap.Tracks, activeTracks),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
cb.hub.Publish(&transport.Envelope{
|
cb.hub.Publish(&transport.Envelope{
|
||||||
@@ -139,10 +140,11 @@ func handleTrackWSMessage(c *realtime.Client, svc *catalog.Service, env *transpo
|
|||||||
out = filterTracksByTag(out, req.Tag)
|
out = filterTracksByTag(out, req.Tag)
|
||||||
}
|
}
|
||||||
snap := svc.Snapshot()
|
snap := svc.Snapshot()
|
||||||
|
activeTracks := getActiveTracks(wsCtx(), svc)
|
||||||
sendWS(c, transport.TypeTrackSnapshot, transport.TrackSnapshot{
|
sendWS(c, transport.TypeTrackSnapshot, transport.TrackSnapshot{
|
||||||
GeneratedMs: snap.GeneratedMs,
|
GeneratedMs: snap.GeneratedMs,
|
||||||
Version: snap.Version,
|
Version: snap.Version,
|
||||||
Tracks: tracksToWire(out),
|
Tracks: tracksToWire(out, activeTracks),
|
||||||
})
|
})
|
||||||
case transport.TypeTrackGet:
|
case transport.TypeTrackGet:
|
||||||
var req transport.TrackGetRequest
|
var req transport.TrackGetRequest
|
||||||
@@ -156,8 +158,9 @@ func handleTrackWSMessage(c *realtime.Client, svc *catalog.Service, env *transpo
|
|||||||
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, ID: req.ID, Error: "not_found"})
|
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, ID: req.ID, Error: "not_found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
activeTracks := getActiveTracks(wsCtx(), svc)
|
||||||
sendWS(c, transport.TypeTrackSnapshot, transport.TrackSnapshot{
|
sendWS(c, transport.TypeTrackSnapshot, transport.TrackSnapshot{
|
||||||
Tracks: []transport.TrackWire{trackToWire(t)},
|
Tracks: []transport.TrackWire{trackToWire(t, activeTracks)},
|
||||||
})
|
})
|
||||||
case transport.TypeTrackCreate:
|
case transport.TypeTrackCreate:
|
||||||
var req transport.TrackCreateRequest
|
var req transport.TrackCreateRequest
|
||||||
@@ -173,7 +176,8 @@ func handleTrackWSMessage(c *realtime.Client, svc *catalog.Service, env *transpo
|
|||||||
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, Error: err.Error()})
|
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: true, ID: created.ID, Track: trackToWire(created)})
|
activeTracks := getActiveTracks(wsCtx(), svc)
|
||||||
|
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: true, ID: created.ID, Track: trackToWire(created, activeTracks)})
|
||||||
case transport.TypeTrackUpdate:
|
case transport.TypeTrackUpdate:
|
||||||
var req transport.TrackUpdateRequest
|
var req transport.TrackUpdateRequest
|
||||||
_ = json.Unmarshal(payloadBytes, &req)
|
_ = json.Unmarshal(payloadBytes, &req)
|
||||||
@@ -183,7 +187,8 @@ func handleTrackWSMessage(c *realtime.Client, svc *catalog.Service, env *transpo
|
|||||||
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, ID: req.ID, Error: err.Error()})
|
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, ID: req.ID, Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: true, ID: updated.ID, Track: trackToWire(updated)})
|
activeTracks := getActiveTracks(wsCtx(), svc)
|
||||||
|
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: true, ID: updated.ID, Track: trackToWire(updated, activeTracks)})
|
||||||
case transport.TypeTrackDelete:
|
case transport.TypeTrackDelete:
|
||||||
var req transport.TrackDeleteRequest
|
var req transport.TrackDeleteRequest
|
||||||
_ = json.Unmarshal(payloadBytes, &req)
|
_ = json.Unmarshal(payloadBytes, &req)
|
||||||
|
|||||||
@@ -118,3 +118,66 @@ func leaderboardEntryToWire(e races.LeaderboardEntry) transport.LeaderboardEntry
|
|||||||
Rank: e.Rank,
|
Rank: e.Rank,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// calendarLeaderboardHandler godoc
|
||||||
|
// @Summary Track Calendar Leaderboards
|
||||||
|
// @Description Returns the list of scheduled tracks with their status (finished/active/planned), top 3 drivers, and current driver position, sorted by calendar start date.
|
||||||
|
// @Tags leaderboard
|
||||||
|
// @Produce json
|
||||||
|
// @Param year query int false "Year. Defaults to current year."
|
||||||
|
// @Param X-Driver-Id header string false "Current driver ID. Defaults to driver-seed-001 (mock Alice)."
|
||||||
|
// @Success 200 {object} transport.TrackCalendarLeaderboardResponse "Calendar leaderboards list"
|
||||||
|
// @Failure 400 {object} map[string]interface{} "Invalid input"
|
||||||
|
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||||
|
// @Router /api/leaderboard/tracks [get]
|
||||||
|
func calendarLeaderboardHandler(svc *races.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
yearVal := r.URL.Query().Get("year")
|
||||||
|
var year int
|
||||||
|
if yearVal != "" {
|
||||||
|
var err error
|
||||||
|
year, err = strconv.Atoi(yearVal)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_input", "invalid year")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
driverID := r.Header.Get("X-Driver-Id")
|
||||||
|
if driverID == "" {
|
||||||
|
driverID = "driver-seed-001" // mock current driver
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := svc.GetCalendarLeaderboard(r.Context(), year, driverID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]transport.TrackCalendarLeaderboard, 0, len(res))
|
||||||
|
for _, item := range res {
|
||||||
|
podium := make([]transport.LeaderboardEntry, 0, len(item.Podium))
|
||||||
|
for _, entry := range item.Podium {
|
||||||
|
podium = append(podium, leaderboardEntryToWire(entry))
|
||||||
|
}
|
||||||
|
|
||||||
|
var curDriver *transport.LeaderboardEntry
|
||||||
|
if item.CurrentDriver != nil {
|
||||||
|
wire := leaderboardEntryToWire(*item.CurrentDriver)
|
||||||
|
curDriver = &wire
|
||||||
|
}
|
||||||
|
|
||||||
|
items = append(items, transport.TrackCalendarLeaderboard{
|
||||||
|
TrackID: item.TrackID,
|
||||||
|
TrackName: item.TrackName,
|
||||||
|
StartAt: item.StartAt,
|
||||||
|
EndAt: item.EndAt,
|
||||||
|
Status: item.Status,
|
||||||
|
Podium: podium,
|
||||||
|
CurrentDriver: curDriver,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, transport.TrackCalendarLeaderboardResponse{Items: items})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ func main() {
|
|||||||
|
|
||||||
// Restore the in-memory lobby from Postgres so active races and
|
// Restore the in-memory lobby from Postgres so active races and
|
||||||
// driver presence survive a restart.
|
// driver presence survive a restart.
|
||||||
restoreCtx, restoreCancel := context.WithTimeout(ctx, 5*time.Second)
|
restoreCtx, restoreCancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
if racesRestored, driversRestored, err := racesSvc.RestoreFromDB(restoreCtx); err != nil {
|
if racesRestored, driversRestored, err := racesSvc.RestoreFromDB(restoreCtx); err != nil {
|
||||||
logger.Warn("restore from db failed", "err", err)
|
logger.Warn("restore from db failed", "err", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -293,6 +293,8 @@ func main() {
|
|||||||
mux.HandleFunc("/api/catalog", catalogHandler(cat))
|
mux.HandleFunc("/api/catalog", catalogHandler(cat))
|
||||||
mux.HandleFunc("/api/tracks", tracksRouter(cat))
|
mux.HandleFunc("/api/tracks", tracksRouter(cat))
|
||||||
mux.HandleFunc("/api/tracks/", trackByIDHandler(cat))
|
mux.HandleFunc("/api/tracks/", trackByIDHandler(cat))
|
||||||
|
mux.HandleFunc("/api/tracks/calendar", trackCalendarRouter(cat))
|
||||||
|
mux.HandleFunc("/api/tracks/calendar/", deleteTrackCalendarHandler(cat))
|
||||||
mux.HandleFunc("/api/cars", carsRouter(cat))
|
mux.HandleFunc("/api/cars", carsRouter(cat))
|
||||||
mux.HandleFunc("/api/cars/", carByIDHandler(cat))
|
mux.HandleFunc("/api/cars/", carByIDHandler(cat))
|
||||||
mux.HandleFunc("/api/races", racesListHandler(racesSvc))
|
mux.HandleFunc("/api/races", racesListHandler(racesSvc))
|
||||||
@@ -302,6 +304,7 @@ func main() {
|
|||||||
mux.HandleFunc("/api/races/plans", racePlansRouter(racesSvc))
|
mux.HandleFunc("/api/races/plans", racePlansRouter(racesSvc))
|
||||||
mux.HandleFunc("/api/races/plans/", racePlansDeleteHandler(racesSvc))
|
mux.HandleFunc("/api/races/plans/", racePlansDeleteHandler(racesSvc))
|
||||||
mux.HandleFunc("/api/leaderboard", leaderboardHandler(racesSvc))
|
mux.HandleFunc("/api/leaderboard", leaderboardHandler(racesSvc))
|
||||||
|
mux.HandleFunc("/api/leaderboard/tracks", calendarLeaderboardHandler(racesSvc))
|
||||||
mux.HandleFunc("/api/clans", clansRouter(clansSvc))
|
mux.HandleFunc("/api/clans", clansRouter(clansSvc))
|
||||||
mux.HandleFunc("/api/clans/", clansByIDHandler(clansSvc))
|
mux.HandleFunc("/api/clans/", clansByIDHandler(clansSvc))
|
||||||
mux.HandleFunc("/api/drivers", driversRouter(driversSvc))
|
mux.HandleFunc("/api/drivers", driversRouter(driversSvc))
|
||||||
|
|||||||
@@ -1078,6 +1078,54 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/leaderboard/tracks": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the list of scheduled tracks with their status (finished/active/planned), top 3 drivers, and current driver position, sorted by calendar start date.",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"leaderboard"
|
||||||
|
],
|
||||||
|
"summary": "Track Calendar Leaderboards",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Year. Defaults to current year.",
|
||||||
|
"name": "year",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Current driver ID. Defaults to driver-seed-001 (mock Alice).",
|
||||||
|
"name": "X-Driver-Id",
|
||||||
|
"in": "header"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Calendar leaderboards list",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/transport.TrackCalendarLeaderboardResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid input",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal server error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/races": {
|
"/api/races": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns one keyset page of races. Live races (status lobby|racing) come from in-memory lobby.Service; finished races come from Postgres. The two sources are merged and ordered by (started_ms DESC, id ASC). The cursor is opaque: pass back the next_cursor verbatim from a previous page.\n## Filters\n- ` + "`" + `status` + "`" + ` (comma-separated, e.g. \"racing,finished\"). Recognised values: all, lobby, racing, finished.\n- ` + "`" + `track_id` + "`" + ` (exact match against a physical track id; custom lobbies are not supported in this build).\n- ` + "`" + `limit` + "`" + ` (1..200, default 50)",
|
"description": "Returns one keyset page of races. Live races (status lobby|racing) come from in-memory lobby.Service; finished races come from Postgres. The two sources are merged and ordered by (started_ms DESC, id ASC). The cursor is opaque: pass back the next_cursor verbatim from a previous page.\n## Filters\n- ` + "`" + `status` + "`" + ` (comma-separated, e.g. \"racing,finished\"). Recognised values: all, lobby, racing, finished.\n- ` + "`" + `track_id` + "`" + ` (exact match against a physical track id; custom lobbies are not supported in this build).\n- ` + "`" + `limit` + "`" + ` (1..200, default 50)",
|
||||||
@@ -1586,6 +1634,123 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/tracks/calendar": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the full calendar of track active periods.",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"tracks"
|
||||||
|
],
|
||||||
|
"summary": "Get Track Calendar",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "List of scheduled active periods",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/transport.TrackCalendarResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"description": "Schedules an active period for a physical track.",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"tracks"
|
||||||
|
],
|
||||||
|
"summary": "Schedule Track Active Period",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Calendar entry details",
|
||||||
|
"name": "payload",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/transport.TrackCalendarCreateRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Created calendar entry",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/transport.TrackCalendarEntry"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid input",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Track not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/tracks/calendar/{id}": {
|
||||||
|
"delete": {
|
||||||
|
"description": "Deletes a scheduled active period by ID.",
|
||||||
|
"tags": [
|
||||||
|
"tracks"
|
||||||
|
],
|
||||||
|
"summary": "Delete Scheduled Period",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Calendar Entry ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No Content"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/tracks/{id}": {
|
"/api/tracks/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Dispatches on HTTP method. The id is taken from the URL path (` + "`" + `/api/tracks/{id}` + "`" + `). System tracks are immutable and cannot be updated or deleted.",
|
"description": "Dispatches on HTTP method. The id is taken from the URL path (` + "`" + `/api/tracks/{id}` + "`" + `). System tracks are immutable and cannot be updated or deleted.",
|
||||||
@@ -2913,6 +3078,100 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"transport.TrackCalendarCreateRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"end_at": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-07-30T00:00:00Z"
|
||||||
|
},
|
||||||
|
"start_at": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-07-15T00:00:00Z"
|
||||||
|
},
|
||||||
|
"track_id": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "monaco"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transport.TrackCalendarEntry": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"end_at": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-07-30T00:00:00Z"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1
|
||||||
|
},
|
||||||
|
"start_at": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-07-15T00:00:00Z"
|
||||||
|
},
|
||||||
|
"track_id": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "monaco"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transport.TrackCalendarLeaderboard": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"current_driver": {
|
||||||
|
"$ref": "#/definitions/transport.LeaderboardEntry"
|
||||||
|
},
|
||||||
|
"end_at": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-07-30T00:00:00Z"
|
||||||
|
},
|
||||||
|
"podium": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/transport.LeaderboardEntry"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"start_at": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-07-15T00:00:00Z"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "active"
|
||||||
|
},
|
||||||
|
"track_id": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "monaco"
|
||||||
|
},
|
||||||
|
"track_name": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Monaco"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transport.TrackCalendarLeaderboardResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/transport.TrackCalendarLeaderboard"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transport.TrackCalendarResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/transport.TrackCalendarEntry"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"transport.TrackCreateRequest": {
|
"transport.TrackCreateRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -3023,6 +3282,10 @@ const docTemplate = `{
|
|||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"is_active": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
"lane_width_m": {
|
"lane_width_m": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1076,6 +1076,54 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/leaderboard/tracks": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the list of scheduled tracks with their status (finished/active/planned), top 3 drivers, and current driver position, sorted by calendar start date.",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"leaderboard"
|
||||||
|
],
|
||||||
|
"summary": "Track Calendar Leaderboards",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Year. Defaults to current year.",
|
||||||
|
"name": "year",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Current driver ID. Defaults to driver-seed-001 (mock Alice).",
|
||||||
|
"name": "X-Driver-Id",
|
||||||
|
"in": "header"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Calendar leaderboards list",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/transport.TrackCalendarLeaderboardResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid input",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal server error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/races": {
|
"/api/races": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns one keyset page of races. Live races (status lobby|racing) come from in-memory lobby.Service; finished races come from Postgres. The two sources are merged and ordered by (started_ms DESC, id ASC). The cursor is opaque: pass back the next_cursor verbatim from a previous page.\n## Filters\n- `status` (comma-separated, e.g. \"racing,finished\"). Recognised values: all, lobby, racing, finished.\n- `track_id` (exact match against a physical track id; custom lobbies are not supported in this build).\n- `limit` (1..200, default 50)",
|
"description": "Returns one keyset page of races. Live races (status lobby|racing) come from in-memory lobby.Service; finished races come from Postgres. The two sources are merged and ordered by (started_ms DESC, id ASC). The cursor is opaque: pass back the next_cursor verbatim from a previous page.\n## Filters\n- `status` (comma-separated, e.g. \"racing,finished\"). Recognised values: all, lobby, racing, finished.\n- `track_id` (exact match against a physical track id; custom lobbies are not supported in this build).\n- `limit` (1..200, default 50)",
|
||||||
@@ -1584,6 +1632,123 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/tracks/calendar": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the full calendar of track active periods.",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"tracks"
|
||||||
|
],
|
||||||
|
"summary": "Get Track Calendar",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "List of scheduled active periods",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/transport.TrackCalendarResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"description": "Schedules an active period for a physical track.",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"tracks"
|
||||||
|
],
|
||||||
|
"summary": "Schedule Track Active Period",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Calendar entry details",
|
||||||
|
"name": "payload",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/transport.TrackCalendarCreateRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Created calendar entry",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/transport.TrackCalendarEntry"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid input",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Track not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/tracks/calendar/{id}": {
|
||||||
|
"delete": {
|
||||||
|
"description": "Deletes a scheduled active period by ID.",
|
||||||
|
"tags": [
|
||||||
|
"tracks"
|
||||||
|
],
|
||||||
|
"summary": "Delete Scheduled Period",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Calendar Entry ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No Content"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/tracks/{id}": {
|
"/api/tracks/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Dispatches on HTTP method. The id is taken from the URL path (`/api/tracks/{id}`). System tracks are immutable and cannot be updated or deleted.",
|
"description": "Dispatches on HTTP method. The id is taken from the URL path (`/api/tracks/{id}`). System tracks are immutable and cannot be updated or deleted.",
|
||||||
@@ -2911,6 +3076,100 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"transport.TrackCalendarCreateRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"end_at": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-07-30T00:00:00Z"
|
||||||
|
},
|
||||||
|
"start_at": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-07-15T00:00:00Z"
|
||||||
|
},
|
||||||
|
"track_id": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "monaco"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transport.TrackCalendarEntry": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"end_at": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-07-30T00:00:00Z"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1
|
||||||
|
},
|
||||||
|
"start_at": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-07-15T00:00:00Z"
|
||||||
|
},
|
||||||
|
"track_id": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "monaco"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transport.TrackCalendarLeaderboard": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"current_driver": {
|
||||||
|
"$ref": "#/definitions/transport.LeaderboardEntry"
|
||||||
|
},
|
||||||
|
"end_at": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-07-30T00:00:00Z"
|
||||||
|
},
|
||||||
|
"podium": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/transport.LeaderboardEntry"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"start_at": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-07-15T00:00:00Z"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "active"
|
||||||
|
},
|
||||||
|
"track_id": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "monaco"
|
||||||
|
},
|
||||||
|
"track_name": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Monaco"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transport.TrackCalendarLeaderboardResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/transport.TrackCalendarLeaderboard"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transport.TrackCalendarResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/transport.TrackCalendarEntry"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"transport.TrackCreateRequest": {
|
"transport.TrackCreateRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -3021,6 +3280,10 @@
|
|||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"is_active": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
"lane_width_m": {
|
"lane_width_m": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -644,6 +644,71 @@ definitions:
|
|||||||
width_m:
|
width_m:
|
||||||
type: number
|
type: number
|
||||||
type: object
|
type: object
|
||||||
|
transport.TrackCalendarCreateRequest:
|
||||||
|
properties:
|
||||||
|
end_at:
|
||||||
|
example: "2026-07-30T00:00:00Z"
|
||||||
|
type: string
|
||||||
|
start_at:
|
||||||
|
example: "2026-07-15T00:00:00Z"
|
||||||
|
type: string
|
||||||
|
track_id:
|
||||||
|
example: monaco
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
transport.TrackCalendarEntry:
|
||||||
|
properties:
|
||||||
|
end_at:
|
||||||
|
example: "2026-07-30T00:00:00Z"
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
example: 1
|
||||||
|
type: integer
|
||||||
|
start_at:
|
||||||
|
example: "2026-07-15T00:00:00Z"
|
||||||
|
type: string
|
||||||
|
track_id:
|
||||||
|
example: monaco
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
transport.TrackCalendarLeaderboard:
|
||||||
|
properties:
|
||||||
|
current_driver:
|
||||||
|
$ref: '#/definitions/transport.LeaderboardEntry'
|
||||||
|
end_at:
|
||||||
|
example: "2026-07-30T00:00:00Z"
|
||||||
|
type: string
|
||||||
|
podium:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/transport.LeaderboardEntry'
|
||||||
|
type: array
|
||||||
|
start_at:
|
||||||
|
example: "2026-07-15T00:00:00Z"
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
example: active
|
||||||
|
type: string
|
||||||
|
track_id:
|
||||||
|
example: monaco
|
||||||
|
type: string
|
||||||
|
track_name:
|
||||||
|
example: Monaco
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
transport.TrackCalendarLeaderboardResponse:
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/transport.TrackCalendarLeaderboard'
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
|
transport.TrackCalendarResponse:
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/transport.TrackCalendarEntry'
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
transport.TrackCreateRequest:
|
transport.TrackCreateRequest:
|
||||||
properties:
|
properties:
|
||||||
track:
|
track:
|
||||||
@@ -716,6 +781,9 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
|
is_active:
|
||||||
|
example: true
|
||||||
|
type: boolean
|
||||||
lane_width_m:
|
lane_width_m:
|
||||||
type: number
|
type: number
|
||||||
length_m:
|
length_m:
|
||||||
@@ -1505,6 +1573,39 @@ paths:
|
|||||||
summary: Leaderboard
|
summary: Leaderboard
|
||||||
tags:
|
tags:
|
||||||
- leaderboard
|
- leaderboard
|
||||||
|
/api/leaderboard/tracks:
|
||||||
|
get:
|
||||||
|
description: Returns the list of scheduled tracks with their status (finished/active/planned),
|
||||||
|
top 3 drivers, and current driver position, sorted by calendar start date.
|
||||||
|
parameters:
|
||||||
|
- description: Year. Defaults to current year.
|
||||||
|
in: query
|
||||||
|
name: year
|
||||||
|
type: integer
|
||||||
|
- description: Current driver ID. Defaults to driver-seed-001 (mock Alice).
|
||||||
|
in: header
|
||||||
|
name: X-Driver-Id
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Calendar leaderboards list
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/transport.TrackCalendarLeaderboardResponse'
|
||||||
|
"400":
|
||||||
|
description: Invalid input
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal server error
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
summary: Track Calendar Leaderboards
|
||||||
|
tags:
|
||||||
|
- leaderboard
|
||||||
/api/races:
|
/api/races:
|
||||||
get:
|
get:
|
||||||
description: |-
|
description: |-
|
||||||
@@ -2024,6 +2125,85 @@ paths:
|
|||||||
summary: Get / update / delete a track by id
|
summary: Get / update / delete a track by id
|
||||||
tags:
|
tags:
|
||||||
- tracks
|
- tracks
|
||||||
|
/api/tracks/calendar:
|
||||||
|
get:
|
||||||
|
description: Returns the full calendar of track active periods.
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: List of scheduled active periods
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/transport.TrackCalendarResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal error
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
summary: Get Track Calendar
|
||||||
|
tags:
|
||||||
|
- tracks
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Schedules an active period for a physical track.
|
||||||
|
parameters:
|
||||||
|
- description: Calendar entry details
|
||||||
|
in: body
|
||||||
|
name: payload
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/transport.TrackCalendarCreateRequest'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Created calendar entry
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/transport.TrackCalendarEntry'
|
||||||
|
"400":
|
||||||
|
description: Invalid input
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
"404":
|
||||||
|
description: Track not found
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal error
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
summary: Schedule Track Active Period
|
||||||
|
tags:
|
||||||
|
- tracks
|
||||||
|
/api/tracks/calendar/{id}:
|
||||||
|
delete:
|
||||||
|
description: Deletes a scheduled active period by ID.
|
||||||
|
parameters:
|
||||||
|
- description: Calendar Entry ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: No Content
|
||||||
|
"404":
|
||||||
|
description: Not found
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal error
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
summary: Delete Scheduled Period
|
||||||
|
tags:
|
||||||
|
- tracks
|
||||||
/api/version:
|
/api/version:
|
||||||
get:
|
get:
|
||||||
description: Returns the current server version.
|
description: Returns the current server version.
|
||||||
|
|||||||
@@ -607,3 +607,44 @@ func (s *Service) ListCarsByOwner(ownerID string) []CarMeta {
|
|||||||
}
|
}
|
||||||
return out
|
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)
|
||||||
|
}
|
||||||
@@ -399,3 +399,53 @@ func TestServiceWithoutStore(t *testing.T) {
|
|||||||
t.Fatal("expected error when store is 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
|
// UpdateCarStats applies a partial mutation of read-mostly stats
|
||||||
// fields on a car. Used by the race engine hooks.
|
// fields on a car. Used by the race engine hooks.
|
||||||
UpdateCarStats(ctx context.Context, id string, fn func(*CarMeta)) error
|
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
|
||||||
}
|
}
|
||||||
@@ -11,12 +11,15 @@ type memoryStore struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
tracks map[TrackID]TrackMeta
|
tracks map[TrackID]TrackMeta
|
||||||
cars map[CarID]CarMeta
|
cars map[CarID]CarMeta
|
||||||
|
calendar []TrackCalendarEntry
|
||||||
|
nextID int
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMemoryStore() *memoryStore {
|
func newMemoryStore() *memoryStore {
|
||||||
return &memoryStore{
|
return &memoryStore{
|
||||||
tracks: make(map[TrackID]TrackMeta),
|
tracks: make(map[TrackID]TrackMeta),
|
||||||
cars: make(map[CarID]CarMeta),
|
cars: make(map[CarID]CarMeta),
|
||||||
|
nextID: 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,3 +76,31 @@ func (m *memoryStore) UpdateCarStats(_ context.Context, id string, fn func(*CarM
|
|||||||
m.cars[id] = c
|
m.cars[id] = c
|
||||||
return nil
|
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"`
|
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.
|
// MotorSpec describes the electric motor.
|
||||||
type MotorSpec struct {
|
type MotorSpec struct {
|
||||||
Kind string `json:"kind"` // brushed | brushless
|
Kind string `json:"kind"` // brushed | brushless
|
||||||
|
|||||||
@@ -451,3 +451,47 @@ func (s *PgStore) GetDriverLeaderboardProfile(ctx context.Context, driverID stri
|
|||||||
}
|
}
|
||||||
return &entry, nil
|
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)
|
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++
|
s.PlansInserted++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := r.seedTrackCalendar(ctx); err != nil {
|
||||||
|
return s, fmt.Errorf("seed track calendar: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
s.Duration = time.Since(start)
|
s.Duration = time.Since(start)
|
||||||
return s, nil
|
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 {
|
type planShape struct {
|
||||||
intervalS int
|
intervalS int
|
||||||
count int
|
count int
|
||||||
@@ -251,18 +295,31 @@ type planShape struct {
|
|||||||
// cascade), plus the plan and queue tables. Drivers and clans are
|
// cascade), plus the plan and queue tables. Drivers and clans are
|
||||||
// kept (they're independent of races).
|
// kept (they're independent of races).
|
||||||
func (r *Runner) resetAll(ctx context.Context) error {
|
func (r *Runner) resetAll(ctx context.Context) error {
|
||||||
if _, err := r.pg.Exec(ctx,
|
if _, err := r.pg.Exec(ctx, `DELETE FROM race_queue`); err != nil {
|
||||||
`DELETE FROM races WHERE status IN ('finished','cancelled')`); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := r.pg.Exec(ctx,
|
if _, err := r.pg.Exec(ctx, `DELETE FROM races`); err != nil {
|
||||||
`TRUNCATE TABLE race_plans, race_queue, drivers, clans CASCADE`); 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
|
return err
|
||||||
}
|
}
|
||||||
for _, m := range r.lb.ListRaces() {
|
for _, m := range r.lb.ListRaces() {
|
||||||
_ = r.lb.DeleteRace(m.ID)
|
_ = 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 err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -740,6 +740,78 @@ func (s *Service) GetLeaderboard(ctx context.Context, trackID string, year int,
|
|||||||
}, nil
|
}, 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
|
// Scheduler
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -112,8 +112,8 @@ func NewPgStore(pool *pgxpool.Pool) *PgStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Exec exposes a raw Exec for the seeder / maintenance scripts.
|
// Exec exposes a raw Exec for the seeder / maintenance scripts.
|
||||||
func (s *PgStore) Exec(ctx context.Context, sql string) (int64, error) {
|
func (s *PgStore) Exec(ctx context.Context, sql string, args ...any) (int64, error) {
|
||||||
tag, err := s.pool.Exec(ctx, sql)
|
tag, err := s.pool.Exec(ctx, sql, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
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);
|
||||||
@@ -371,3 +371,51 @@ func (s *PgStore) UpdateCarStats(ctx context.Context, id string, fn func(*catalo
|
|||||||
}
|
}
|
||||||
return tx.Commit(ctx)
|
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"`
|
BestLapHolder string `json:"best_lap_holder,omitempty"`
|
||||||
CreatedMs int64 `json:"created_ms"`
|
CreatedMs int64 `json:"created_ms"`
|
||||||
UpdatedMs int64 `json:"updated_ms"`
|
UpdatedMs int64 `json:"updated_ms"`
|
||||||
|
IsActive bool `json:"is_active" example:"true"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrackListRequest: payload { "scope": "all|system|public|private", "tag": "..." }.
|
// TrackListRequest: payload { "scope": "all|system|public|private", "tag": "..." }.
|
||||||
@@ -862,6 +863,42 @@ type LeaderboardResponse struct {
|
|||||||
CurrentDriver *LeaderboardEntry `json:"current_driver,omitempty"`
|
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.
|
// Encode marshals an envelope to JSON bytes.
|
||||||
func Encode(env *Envelope) ([]byte, error) {
|
func Encode(env *Envelope) ([]byte, error) {
|
||||||
if env.TSMs == 0 {
|
if env.TSMs == 0 {
|
||||||
|
|||||||
Reference in New Issue
Block a user