diff --git a/server/cmd/poc-server/catalog_handlers.go b/server/cmd/poc-server/catalog_handlers.go index 29cb166..338c7b9 100644 --- a/server/cmd/poc-server/catalog_handlers.go +++ b/server/cmd/poc-server/catalog_handlers.go @@ -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) + } +} diff --git a/server/cmd/poc-server/catalog_ws.go b/server/cmd/poc-server/catalog_ws.go index 989ba04..7f5572f 100644 --- a/server/cmd/poc-server/catalog_ws.go +++ b/server/cmd/poc-server/catalog_ws.go @@ -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) diff --git a/server/cmd/poc-server/leaderboard_handlers.go b/server/cmd/poc-server/leaderboard_handlers.go index d2a02fa..47a3aa4 100644 --- a/server/cmd/poc-server/leaderboard_handlers.go +++ b/server/cmd/poc-server/leaderboard_handlers.go @@ -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}) + } +} diff --git a/server/cmd/poc-server/main.go b/server/cmd/poc-server/main.go index 208a310..53f48b2 100644 --- a/server/cmd/poc-server/main.go +++ b/server/cmd/poc-server/main.go @@ -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)) diff --git a/server/docs/docs.go b/server/docs/docs.go index 6b7adca..0a0922c 100644 --- a/server/docs/docs.go +++ b/server/docs/docs.go @@ -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": { "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)", @@ -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}": { "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.", @@ -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": { "type": "object", "properties": { @@ -3023,6 +3282,10 @@ const docTemplate = `{ "id": { "type": "string" }, + "is_active": { + "type": "boolean", + "example": true + }, "lane_width_m": { "type": "number" }, diff --git a/server/docs/swagger.json b/server/docs/swagger.json index c043751..b444bfb 100644 --- a/server/docs/swagger.json +++ b/server/docs/swagger.json @@ -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": { "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)", @@ -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}": { "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.", @@ -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": { "type": "object", "properties": { @@ -3021,6 +3280,10 @@ "id": { "type": "string" }, + "is_active": { + "type": "boolean", + "example": true + }, "lane_width_m": { "type": "number" }, diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml index 65a1660..f747c78 100644 --- a/server/docs/swagger.yaml +++ b/server/docs/swagger.yaml @@ -644,6 +644,71 @@ definitions: width_m: type: number 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: properties: track: @@ -716,6 +781,9 @@ definitions: type: string id: type: string + is_active: + example: true + type: boolean lane_width_m: type: number length_m: @@ -1505,6 +1573,39 @@ paths: summary: Leaderboard tags: - 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: get: description: |- @@ -2024,6 +2125,85 @@ paths: summary: Get / update / delete a track by id tags: - 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: get: description: Returns the current server version. diff --git a/server/internal/catalog/catalog.go b/server/internal/catalog/catalog.go index a744635..8878655 100644 --- a/server/internal/catalog/catalog.go +++ b/server/internal/catalog/catalog.go @@ -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) } \ No newline at end of file diff --git a/server/internal/catalog/catalog_test.go b/server/internal/catalog/catalog_test.go index 2c1a410..467c672 100644 --- a/server/internal/catalog/catalog_test.go +++ b/server/internal/catalog/catalog_test.go @@ -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)) + } } \ No newline at end of file diff --git a/server/internal/catalog/store.go b/server/internal/catalog/store.go index 3c861fc..0860a0a 100644 --- a/server/internal/catalog/store.go +++ b/server/internal/catalog/store.go @@ -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 } \ No newline at end of file diff --git a/server/internal/catalog/store_memory.go b/server/internal/catalog/store_memory.go index 4e2155e..75c291a 100644 --- a/server/internal/catalog/store_memory.go +++ b/server/internal/catalog/store_memory.go @@ -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 } \ No newline at end of file diff --git a/server/internal/catalog/types.go b/server/internal/catalog/types.go index fbabe4d..c2f4bb9 100644 --- a/server/internal/catalog/types.go +++ b/server/internal/catalog/types.go @@ -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 diff --git a/server/internal/races/leaderboard.go b/server/internal/races/leaderboard.go index 101f611..4c25c8a 100644 --- a/server/internal/races/leaderboard.go +++ b/server/internal/races/leaderboard.go @@ -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 +} diff --git a/server/internal/races/leaderboard_test.go b/server/internal/races/leaderboard_test.go index 5f3fd96..bc020e0 100644 --- a/server/internal/races/leaderboard_test.go +++ b/server/internal/races/leaderboard_test.go @@ -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) + } + }) } diff --git a/server/internal/races/seed/seed.go b/server/internal/races/seed/seed.go index 3e5a682..dc3e494 100644 --- a/server/internal/races/seed/seed.go +++ b/server/internal/races/seed/seed.go @@ -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 diff --git a/server/internal/races/service.go b/server/internal/races/service.go index e2e75d9..033b1ec 100644 --- a/server/internal/races/service.go +++ b/server/internal/races/service.go @@ -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 // --------------------------------------------------------------------------- diff --git a/server/internal/races/store.go b/server/internal/races/store.go index 35c3a5b..ba24d88 100644 --- a/server/internal/races/store.go +++ b/server/internal/races/store.go @@ -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 } diff --git a/server/internal/storage/postgres/migrations/014_track_calendar.sql b/server/internal/storage/postgres/migrations/014_track_calendar.sql new file mode 100644 index 0000000..f789556 --- /dev/null +++ b/server/internal/storage/postgres/migrations/014_track_calendar.sql @@ -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); diff --git a/server/internal/storage/postgres/pgstore.go b/server/internal/storage/postgres/pgstore.go index 981f2f4..7ba0a2f 100644 --- a/server/internal/storage/postgres/pgstore.go +++ b/server/internal/storage/postgres/pgstore.go @@ -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 } \ No newline at end of file diff --git a/server/internal/transport/codec.go b/server/internal/transport/codec.go index a9e2e29..cfcbe93 100644 --- a/server/internal/transport/codec.go +++ b/server/internal/transport/codec.go @@ -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 {