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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
@@ -92,20 +94,36 @@ func trackToWire(t catalog.TrackMeta) transport.TrackWire {
|
||||
BestLapHolder: t.BestLapHolder,
|
||||
CreatedMs: t.CreatedMs,
|
||||
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 {
|
||||
return []transport.TrackWire{}
|
||||
}
|
||||
out := make([]transport.TrackWire, len(in))
|
||||
for i, t := range in {
|
||||
out[i] = trackToWire(t)
|
||||
out[i] = trackToWire(t, activeTracks)
|
||||
}
|
||||
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 {
|
||||
return catalog.TrackMeta{
|
||||
ID: w.ID,
|
||||
@@ -400,10 +418,11 @@ func mapCatalogError(err error) (int, string) {
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/catalog [get]
|
||||
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()
|
||||
activeTracks := getActiveTracks(r.Context(), svc)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"tracks": tracksToWire(snap.Tracks),
|
||||
"tracks": tracksToWire(snap.Tracks, activeTracks),
|
||||
"cars": carsToWire(snap.Cars),
|
||||
"summary": summaryToWire(svc.Stats()),
|
||||
})
|
||||
@@ -439,8 +458,9 @@ func tracksHandler(svc *catalog.Service) http.HandlerFunc {
|
||||
if tag != "" {
|
||||
out = filterTracksByTag(out, tag)
|
||||
}
|
||||
activeTracks := getActiveTracks(r.Context(), svc)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"tracks": tracksToWire(out),
|
||||
"tracks": tracksToWire(out, activeTracks),
|
||||
"count": len(out),
|
||||
})
|
||||
}
|
||||
@@ -471,6 +491,7 @@ func trackByIDHandler(svc *catalog.Service) http.HandlerFunc {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "missing track id")
|
||||
return
|
||||
}
|
||||
activeTracks := getActiveTracks(r.Context(), svc)
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
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")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, trackToWire(t))
|
||||
writeJSON(w, http.StatusOK, trackToWire(t, activeTracks))
|
||||
case http.MethodPut:
|
||||
var req transport.TrackUpdateRequest
|
||||
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())
|
||||
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:
|
||||
if err := svc.DeleteTrack(r.Context(), id); err != nil {
|
||||
status, code := mapCatalogError(err)
|
||||
@@ -549,7 +570,8 @@ func createTrackHandler(svc *catalog.Service) http.HandlerFunc {
|
||||
writeError(w, status, code, err.Error())
|
||||
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
|
||||
case catalog.EventTrackUpsert, catalog.EventTrackDelete:
|
||||
snap := cb.svc.Snapshot()
|
||||
activeTracks := getActiveTracks(context.Background(), cb.svc)
|
||||
cb.hub.Publish(&transport.Envelope{
|
||||
Type: transport.TypeTrackSnapshot,
|
||||
Payload: transport.TrackSnapshot{
|
||||
GeneratedMs: snap.GeneratedMs,
|
||||
Version: snap.Version,
|
||||
Tracks: tracksToWire(snap.Tracks),
|
||||
Tracks: tracksToWire(snap.Tracks, activeTracks),
|
||||
},
|
||||
})
|
||||
cb.hub.Publish(&transport.Envelope{
|
||||
@@ -139,10 +140,11 @@ func handleTrackWSMessage(c *realtime.Client, svc *catalog.Service, env *transpo
|
||||
out = filterTracksByTag(out, req.Tag)
|
||||
}
|
||||
snap := svc.Snapshot()
|
||||
activeTracks := getActiveTracks(wsCtx(), svc)
|
||||
sendWS(c, transport.TypeTrackSnapshot, transport.TrackSnapshot{
|
||||
GeneratedMs: snap.GeneratedMs,
|
||||
Version: snap.Version,
|
||||
Tracks: tracksToWire(out),
|
||||
Tracks: tracksToWire(out, activeTracks),
|
||||
})
|
||||
case transport.TypeTrackGet:
|
||||
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"})
|
||||
return
|
||||
}
|
||||
activeTracks := getActiveTracks(wsCtx(), svc)
|
||||
sendWS(c, transport.TypeTrackSnapshot, transport.TrackSnapshot{
|
||||
Tracks: []transport.TrackWire{trackToWire(t)},
|
||||
Tracks: []transport.TrackWire{trackToWire(t, activeTracks)},
|
||||
})
|
||||
case transport.TypeTrackCreate:
|
||||
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()})
|
||||
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:
|
||||
var req transport.TrackUpdateRequest
|
||||
_ = 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()})
|
||||
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:
|
||||
var req transport.TrackDeleteRequest
|
||||
_ = json.Unmarshal(payloadBytes, &req)
|
||||
|
||||
@@ -118,3 +118,66 @@ func leaderboardEntryToWire(e races.LeaderboardEntry) transport.LeaderboardEntry
|
||||
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
|
||||
// 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 {
|
||||
logger.Warn("restore from db failed", "err", err)
|
||||
} else {
|
||||
@@ -293,6 +293,8 @@ func main() {
|
||||
mux.HandleFunc("/api/catalog", catalogHandler(cat))
|
||||
mux.HandleFunc("/api/tracks", tracksRouter(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/", carByIDHandler(cat))
|
||||
mux.HandleFunc("/api/races", racesListHandler(racesSvc))
|
||||
@@ -302,6 +304,7 @@ func main() {
|
||||
mux.HandleFunc("/api/races/plans", racePlansRouter(racesSvc))
|
||||
mux.HandleFunc("/api/races/plans/", racePlansDeleteHandler(racesSvc))
|
||||
mux.HandleFunc("/api/leaderboard", leaderboardHandler(racesSvc))
|
||||
mux.HandleFunc("/api/leaderboard/tracks", calendarLeaderboardHandler(racesSvc))
|
||||
mux.HandleFunc("/api/clans", clansRouter(clansSvc))
|
||||
mux.HandleFunc("/api/clans/", clansByIDHandler(clansSvc))
|
||||
mux.HandleFunc("/api/drivers", driversRouter(driversSvc))
|
||||
|
||||
Reference in New Issue
Block a user