diff --git a/API_CHANGES.md b/API_CHANGES.md new file mode 100644 index 0000000..f20b89c --- /dev/null +++ b/API_CHANGES.md @@ -0,0 +1,182 @@ +# API Changes (Not Yet Reflected in `backend/openapi/openapi.json`) + +This document lists additive API changes currently implemented in the Go backend. + +## Summary + +Existing endpoints remain compatible. + +Added capabilities: + +1. Position board persistence for live/admin ordering. +2. Stage session control (`start`/`end`) for event flow. +3. Stage history retrieval. +4. New state fields (`positionBoards`, `current_stage`, `last_stage`). + +## 1) Position Board Endpoint + +- Method: `PUT` +- Path: `/api/admin/positions/:board` +- Auth: admin bearer token (`Authorization: Bearer `) + +### Supported `:board` values + +- `preliminary` +- `final` +- `prelim_tiebreak` +- `final_tiebreak` + +### Request body + +```json +{ + "slots": [ + { "playerId": 12, "groupKey": "A", "position": 1 }, + { "playerId": 15, "groupKey": "A", "position": 2 }, + { "playerId": 20, "groupKey": "B", "position": 1 } + ] +} +``` + +Fields: + +- `playerId` (integer, required) +- `groupKey` (string, required) +- `position` (integer > 0, required) + +Notes: + +- The request replaces all saved slots for the target board. +- For `preliminary`, `players.group_code` is synchronized from `groupKey`. +- Special value `UNASSIGNED` maps to empty player group. + +### Response + +- `200 OK` with full updated admin state payload. + +## 2) Stage Session Control Endpoints + +### Start stage + +- Method: `POST` +- Path: `/api/admin/stage/start` +- Auth: admin bearer token + +Request body: + +```json +{ + "phase": "preliminary", + "group": "A", + "round": 2 +} +``` + +Rules: + +- `phase` must be one of: + - `preliminary` + - `prelim_tiebreak` + - `final` + - `final_tiebreak` +- `group` validation depends on phase: + - `preliminary`: group code string (or `UNASSIGNED`) + - `final`: `"1"` or `"2"` + - tie-break phases: positive numeric string +- `round` normalization: + - `preliminary`: `1..3` + - `final`: `1..2` + - tie-break phases: always `1` +- If another stage is active (started, not ended), API returns `409`. + +Response: + +- `200 OK` with full updated admin state payload. + +### End stage + +- Method: `POST` +- Path: `/api/admin/stage/end` +- Auth: admin bearer token + +Rules: + +- Ends the currently active stage by setting `ended_at`. +- If no active stage exists, API returns `400`. + +Response: + +- `200 OK` with full updated admin state payload. + +## 3) Stage History Endpoint + +- Method: `GET` +- Path: `/api/admin/stage/history` +- Auth: admin bearer token + +Response: + +```json +{ + "items": [ + { + "id": 1, + "phase": "preliminary", + "group": "A", + "round": 2, + "startedAt": "2026-04-30T08:27:13Z", + "endedAt": "2026-04-30T08:28:01Z" + } + ] +} +``` + +## 4) New State Fields + +Both endpoints now include these fields: + +- `GET /api/state` +- `GET /api/admin/state` + +```json +{ + "positionBoards": { + "preliminary": { + "12": { "groupKey": "A", "position": 1 } + }, + "final": { + "12": { "groupKey": "1", "position": 3 } + }, + "prelim_tiebreak": {}, + "final_tiebreak": {} + }, + "current_stage": { + "id": 7, + "phase": "preliminary", + "group": "A", + "round": 2, + "startedAt": "2026-04-30T09:00:00Z" + }, + "last_stage": { + "id": 6, + "phase": "final", + "group": "1", + "round": 1, + "startedAt": "2026-04-30T08:45:00Z", + "endedAt": "2026-04-30T08:55:00Z" + } +} +``` + +Notes: + +- `positionBoards` keys are player IDs as strings. +- `current_stage` is `null` when no stage is active. +- `last_stage` is `null` when no stage session has ever started. +- `last_stage` is the most recently started session (ended or still active). + +## Compatibility Impact + +- Additive only; existing consumers remain functional. +- Consumers can ignore unknown fields safely. +- Consumers needing control/monitoring can use new stage endpoints and fields. diff --git a/backend/db.go b/backend/db.go index 563c6c3..9fbb872 100644 --- a/backend/db.go +++ b/backend/db.go @@ -59,12 +59,31 @@ CREATE TABLE IF NOT EXISTS score_attachments ( FOREIGN KEY(player_id) REFERENCES players(id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS player_positions ( + board TEXT NOT NULL, + player_id INTEGER NOT NULL, + group_key TEXT NOT NULL DEFAULT '', + position INTEGER NOT NULL DEFAULT 1, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(board, player_id), + FOREIGN KEY(player_id) REFERENCES players(id) ON DELETE CASCADE +); + CREATE TABLE IF NOT EXISTS app_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS stage_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + phase TEXT NOT NULL, + group_key TEXT NOT NULL DEFAULT '', + round INTEGER NOT NULL DEFAULT 1, + started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + ended_at DATETIME +); + INSERT OR IGNORE INTO app_settings(key, value) VALUES ('view_proof_in_view', '0'), ('live_active_view', 'group_live'), diff --git a/backend/handlers.go b/backend/handlers.go index 443808c..ac0151b 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -78,6 +78,84 @@ func (a *App) handleUpdateAdminSettings(c echo.Context) error { return c.JSON(http.StatusOK, state) } +func (a *App) handleStartCurrentStage(c echo.Context) error { + var req StageControlStartRequest + if err := c.Bind(&req); err != nil { + return writeError(c, http.StatusBadRequest, "invalid request body") + } + + phase, ok := normalizeStagePhase(req.Phase) + if !ok { + return writeError(c, http.StatusBadRequest, "invalid phase") + } + groupKey, ok := normalizeStageGroup(phase, req.GroupKey) + if !ok { + return writeError(c, http.StatusBadRequest, "invalid group for selected phase") + } + round := normalizeStageRound(phase, req.Round) + + current, err := a.readCurrentStage() + if err != nil { + return writeError(c, http.StatusInternalServerError, err.Error()) + } + if current != nil { + return writeError(c, http.StatusConflict, "there is already an active stage. end it first") + } + + _, err = a.db.Exec(` +INSERT INTO stage_sessions(phase, group_key, round, started_at) +VALUES(?, ?, ?, CURRENT_TIMESTAMP) +`, phase, groupKey, round) + if err != nil { + return writeError(c, http.StatusInternalServerError, fmt.Sprintf("start stage: %v", err)) + } + + a.events.Broadcast() + + state, err := a.readState(true) + if err != nil { + return writeError(c, http.StatusInternalServerError, err.Error()) + } + return c.JSON(http.StatusOK, state) +} + +func (a *App) handleEndCurrentStage(c echo.Context) error { + current, err := a.readCurrentStage() + if err != nil { + return writeError(c, http.StatusInternalServerError, err.Error()) + } + if current == nil { + return writeError(c, http.StatusBadRequest, "no active stage to end") + } + + _, err = a.db.Exec(` +UPDATE stage_sessions +SET ended_at = CURRENT_TIMESTAMP +WHERE id = ? AND ended_at IS NULL +`, current.ID) + if err != nil { + return writeError(c, http.StatusInternalServerError, fmt.Sprintf("end stage: %v", err)) + } + + a.events.Broadcast() + + state, err := a.readState(true) + if err != nil { + return writeError(c, http.StatusInternalServerError, err.Error()) + } + return c.JSON(http.StatusOK, state) +} + +func (a *App) handleGetStageHistory(c echo.Context) error { + items, err := a.readStageHistory() + if err != nil { + return writeError(c, http.StatusInternalServerError, err.Error()) + } + return c.JSON(http.StatusOK, map[string]any{ + "items": items, + }) +} + func (a *App) handleCreatePlayer(c echo.Context) error { var req PlayerCreateRequest if err := c.Bind(&req); err != nil { @@ -295,6 +373,77 @@ func (a *App) handleAutoGroupPlayers(c echo.Context) error { return c.JSON(http.StatusOK, state) } +func (a *App) handleUpdatePositionBoard(c echo.Context) error { + board, ok := normalizePositionBoard(c.Param("board")) + if !ok { + return writeError(c, http.StatusBadRequest, "invalid position board") + } + + var req PositionBoardUpdateRequest + if err := c.Bind(&req); err != nil { + return writeError(c, http.StatusBadRequest, "invalid request body") + } + + seen := map[int]bool{} + for _, slot := range req.Slots { + if slot.PlayerID <= 0 { + return writeError(c, http.StatusBadRequest, "invalid player id") + } + if seen[slot.PlayerID] { + return writeError(c, http.StatusBadRequest, "duplicate player id in slots") + } + seen[slot.PlayerID] = true + if strings.TrimSpace(slot.GroupKey) == "" { + return writeError(c, http.StatusBadRequest, "groupKey is required") + } + if slot.Position <= 0 { + return writeError(c, http.StatusBadRequest, "position must be positive") + } + } + + tx, err := a.db.Begin() + if err != nil { + return writeError(c, http.StatusInternalServerError, "failed to start position transaction") + } + defer tx.Rollback() + + if _, err := tx.Exec(`DELETE FROM player_positions WHERE board = ?`, board); err != nil { + return writeError(c, http.StatusInternalServerError, fmt.Sprintf("clear board positions: %v", err)) + } + + for _, slot := range req.Slots { + groupKey := normalizePositionGroupKey(board, slot.GroupKey, "") + if _, err := tx.Exec(` +INSERT INTO player_positions(board, player_id, group_key, position, updated_at) +VALUES(?, ?, ?, ?, CURRENT_TIMESTAMP) +`, board, slot.PlayerID, groupKey, slot.Position); err != nil { + return writeError(c, http.StatusInternalServerError, fmt.Sprintf("save board position: %v", err)) + } + + if board == positionBoardPreliminary { + groupCode := strings.TrimSpace(groupKey) + if strings.EqualFold(groupCode, positionBoardUnassigned) { + groupCode = "" + } + if _, err := tx.Exec(`UPDATE players SET group_code = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, groupCode, slot.PlayerID); err != nil { + return writeError(c, http.StatusInternalServerError, fmt.Sprintf("update player group from board: %v", err)) + } + } + } + + if err := tx.Commit(); err != nil { + return writeError(c, http.StatusInternalServerError, "failed to commit positions") + } + + a.events.Broadcast() + + state, err := a.readState(true) + if err != nil { + return writeError(c, http.StatusInternalServerError, err.Error()) + } + return c.JSON(http.StatusOK, state) +} + func (a *App) handleUpdateScore(c echo.Context) error { stage, err := validateStage(c.Param("stage")) if err != nil { diff --git a/backend/models.go b/backend/models.go index 5c02683..fdf3405 100644 --- a/backend/models.go +++ b/backend/models.go @@ -84,13 +84,30 @@ type FinalGroups struct { } type StateResponse struct { - Competition CompetitionMeta `json:"competition"` - Players []Player `json:"players"` - Scores map[string]map[string]int `json:"scores"` - ScoreProofs map[string]map[string]string `json:"scoreProofs,omitempty"` - Settings AppSettings `json:"settings"` - Derived DerivedState `json:"derived"` - ServerTime string `json:"serverTime"` + Competition CompetitionMeta `json:"competition"` + Players []Player `json:"players"` + Scores map[string]map[string]int `json:"scores"` + ScoreProofs map[string]map[string]string `json:"scoreProofs,omitempty"` + PositionBoards map[string]map[string]PositionSlot `json:"positionBoards"` + Settings AppSettings `json:"settings"` + Derived DerivedState `json:"derived"` + CurrentStage *StageSessionState `json:"current_stage"` + LastStage *StageSessionState `json:"last_stage"` + ServerTime string `json:"serverTime"` +} + +type StageSessionState struct { + ID int `json:"id"` + Phase string `json:"phase"` + GroupKey string `json:"group"` + Round int `json:"round"` + StartedAt string `json:"startedAt"` + EndedAt string `json:"endedAt,omitempty"` +} + +type PositionSlot struct { + GroupKey string `json:"groupKey"` + Position int `json:"position"` } type AppSettings struct { @@ -170,6 +187,16 @@ type AutoGroupPlayersRequest struct { Groups []string `json:"groups"` } +type PositionBoardUpdateRequest struct { + Slots []PositionBoardSlotInput `json:"slots"` +} + +type PositionBoardSlotInput struct { + PlayerID int `json:"playerId"` + GroupKey string `json:"groupKey"` + Position int `json:"position"` +} + type ScoreAdviceResponse struct { Stage string `json:"stage"` PlayerID int `json:"playerId"` @@ -177,3 +204,9 @@ type ScoreAdviceResponse struct { Reason string `json:"reason"` GeneratedAt string `json:"generatedAt"` } + +type StageControlStartRequest struct { + Phase string `json:"phase"` + GroupKey string `json:"group"` + Round int `json:"round"` +} diff --git a/backend/openapi/openapi.json b/backend/openapi/openapi.json index 2b224a5..1942fdd 100644 --- a/backend/openapi/openapi.json +++ b/backend/openapi/openapi.json @@ -1080,6 +1080,7 @@ "prelim_overall", "final_groups", "final_tie", + "final_overall", "podium" ] }, @@ -1149,6 +1150,7 @@ "prelim_overall", "final_groups", "final_tie", + "final_overall", "podium" ] }, diff --git a/backend/routes.go b/backend/routes.go index 22d77de..5b95111 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -22,9 +22,13 @@ func registerAPIRoutes(e *echo.Echo, app *App) { api.POST("/admin/login", app.handleAdminLogin) api.POST("/admin/logout", app.handleAdminLogout, app.adminOnly) api.GET("/admin/state", app.handleGetState, app.adminOnly) + api.GET("/admin/stage/history", app.handleGetStageHistory, app.adminOnly) api.PUT("/admin/settings", app.handleUpdateAdminSettings, app.adminOnly) + api.POST("/admin/stage/start", app.handleStartCurrentStage, app.adminOnly) + api.POST("/admin/stage/end", app.handleEndCurrentStage, app.adminOnly) api.POST("/admin/players", app.handleCreatePlayer, app.adminOnly) api.POST("/admin/players/auto-group", app.handleAutoGroupPlayers, app.adminOnly) + api.PUT("/admin/positions/:board", app.handleUpdatePositionBoard, app.adminOnly) api.PUT("/admin/players/:id", app.handleUpdatePlayer, app.adminOnly) api.DELETE("/admin/players/:id", app.handleDeletePlayer, app.adminOnly) api.PUT("/admin/scores/:stage/:id", app.handleUpdateScore, app.adminOnly) diff --git a/backend/settings.go b/backend/settings.go index b7f69ce..60c08a6 100644 --- a/backend/settings.go +++ b/backend/settings.go @@ -355,7 +355,7 @@ func normalizeRotationPlayerCountInt(value int) int { func normalizeLiveActiveView(value string, fallback string) string { switch strings.TrimSpace(strings.ToLower(value)) { - case "group_live", "prelim_tie", "prelim_overall", "final_groups", "final_tie", "podium": + case "group_live", "prelim_tie", "prelim_overall", "final_groups", "final_tie", "final_overall", "podium": return strings.TrimSpace(strings.ToLower(value)) default: if strings.TrimSpace(strings.ToLower(fallback)) != "" { diff --git a/backend/stage_sessions.go b/backend/stage_sessions.go new file mode 100644 index 0000000..51a9445 --- /dev/null +++ b/backend/stage_sessions.go @@ -0,0 +1,172 @@ +package main + +import ( + "database/sql" + "fmt" + "strconv" + "strings" + "time" +) + +const ( + stagePhasePreliminary = "preliminary" + stagePhasePrelimTie = "prelim_tiebreak" + stagePhaseFinal = "final" + stagePhaseFinalTie = "final_tiebreak" +) + +var allowedStagePhases = map[string]bool{ + stagePhasePreliminary: true, + stagePhasePrelimTie: true, + stagePhaseFinal: true, + stagePhaseFinalTie: true, +} + +func normalizeStagePhase(raw string) (string, bool) { + phase := strings.ToLower(strings.TrimSpace(raw)) + if !allowedStagePhases[phase] { + return "", false + } + return phase, true +} + +func normalizeStageGroup(phase string, raw string) (string, bool) { + group := strings.TrimSpace(raw) + switch phase { + case stagePhasePreliminary: + if strings.EqualFold(group, positionBoardUnassigned) { + return positionBoardUnassigned, true + } + group = normalizePreliminaryGroupKey(group) + if group == "" { + return "", false + } + return group, true + case stagePhaseFinal: + parsed, err := strconv.Atoi(group) + if err != nil || (parsed != 1 && parsed != 2) { + return "", false + } + return strconv.Itoa(parsed), true + case stagePhasePrelimTie, stagePhaseFinalTie: + parsed, err := strconv.Atoi(group) + if err != nil || parsed <= 0 { + return "", false + } + return strconv.Itoa(parsed), true + default: + return "", false + } +} + +func normalizeStageRound(phase string, round int) int { + switch phase { + case stagePhasePreliminary: + if round < 1 { + return 1 + } + if round > 3 { + return 3 + } + return round + case stagePhaseFinal: + if round < 1 { + return 1 + } + if round > 2 { + return 2 + } + return round + default: + return 1 + } +} + +func normalizeTimestamp(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "" + } + if parsed, err := time.Parse(time.RFC3339Nano, trimmed); err == nil { + return parsed.UTC().Format(time.RFC3339) + } + if parsed, err := time.Parse("2006-01-02 15:04:05", trimmed); err == nil { + return parsed.UTC().Format(time.RFC3339) + } + return trimmed +} + +func scanStageSession(scanner interface{ Scan(dest ...any) error }) (*StageSessionState, error) { + var item StageSessionState + var ended sql.NullString + if err := scanner.Scan(&item.ID, &item.Phase, &item.GroupKey, &item.Round, &item.StartedAt, &ended); err != nil { + return nil, err + } + item.Phase = strings.ToLower(strings.TrimSpace(item.Phase)) + item.GroupKey = strings.TrimSpace(item.GroupKey) + item.StartedAt = normalizeTimestamp(item.StartedAt) + if ended.Valid { + item.EndedAt = normalizeTimestamp(ended.String) + } + return &item, nil +} + +func (a *App) readCurrentStage() (*StageSessionState, error) { + row := a.db.QueryRow(` +SELECT id, phase, group_key, round, started_at, ended_at +FROM stage_sessions +WHERE ended_at IS NULL +ORDER BY started_at DESC, id DESC +LIMIT 1 +`) + item, err := scanStageSession(row) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("read current stage: %w", err) + } + return item, nil +} + +func (a *App) readLastStage() (*StageSessionState, error) { + row := a.db.QueryRow(` +SELECT id, phase, group_key, round, started_at, ended_at +FROM stage_sessions +ORDER BY started_at DESC, id DESC +LIMIT 1 +`) + item, err := scanStageSession(row) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("read last stage: %w", err) + } + return item, nil +} + +func (a *App) readStageHistory() ([]StageSessionState, error) { + rows, err := a.db.Query(` +SELECT id, phase, group_key, round, started_at, ended_at +FROM stage_sessions +ORDER BY started_at DESC, id DESC +`) + if err != nil { + return nil, fmt.Errorf("query stage history: %w", err) + } + defer rows.Close() + + items := make([]StageSessionState, 0) + for rows.Next() { + item, scanErr := scanStageSession(rows) + if scanErr != nil { + return nil, fmt.Errorf("scan stage history: %w", scanErr) + } + items = append(items, *item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate stage history: %w", err) + } + return items, nil +} diff --git a/backend/state.go b/backend/state.go index 61dd0c7..bcf946c 100644 --- a/backend/state.go +++ b/backend/state.go @@ -8,6 +8,21 @@ import ( "time" ) +const ( + positionBoardPreliminary = "preliminary" + positionBoardFinal = "final" + positionBoardPrelimTie = "prelim_tiebreak" + positionBoardFinalTie = "final_tiebreak" + positionBoardUnassigned = "UNASSIGNED" +) + +var allowedPositionBoards = map[string]bool{ + positionBoardPreliminary: true, + positionBoardFinal: true, + positionBoardPrelimTie: true, + positionBoardFinalTie: true, +} + func (a *App) readState(includeAllProofs bool) (StateResponse, error) { players, err := a.readPlayers() if err != nil { @@ -38,17 +53,32 @@ func (a *App) readState(includeAllProofs bool) (StateResponse, error) { } derived := computeDerived(players, scoreMap) + positionBoards, err := a.readPositionBoards(players, derived) + if err != nil { + return StateResponse{}, err + } + currentStage, err := a.readCurrentStage() + if err != nil { + return StateResponse{}, err + } + lastStage, err := a.readLastStage() + if err != nil { + return StateResponse{}, err + } response := StateResponse{ Competition: CompetitionMeta{ TitleAr: "بطولة دويتوايلر للرماية", TitleEn: "Datwyler Shooting Event", }, - Players: players, - Scores: scoreMapToJSON(scoreMap), - Settings: settings, - Derived: derived, - ServerTime: time.Now().UTC().Format(time.RFC3339), + Players: players, + Scores: scoreMapToJSON(scoreMap), + PositionBoards: positionBoardMapToJSON(positionBoards), + Settings: settings, + Derived: derived, + CurrentStage: currentStage, + LastStage: lastStage, + ServerTime: time.Now().UTC().Format(time.RFC3339), } if includeScoreProofs { response.ScoreProofs = scoreProofMapToJSON(scoreProofs) @@ -133,6 +163,210 @@ func scoreProofMapToJSON(proofMap map[string]map[int]string) map[string]map[stri return out } +func positionBoardMapToJSON(positionMap map[string]map[int]PositionSlot) map[string]map[string]PositionSlot { + out := map[string]map[string]PositionSlot{} + for board := range allowedPositionBoards { + out[board] = map[string]PositionSlot{} + } + for board, boardMap := range positionMap { + if _, ok := out[board]; !ok { + out[board] = map[string]PositionSlot{} + } + for playerID, slot := range boardMap { + out[board][strconv.Itoa(playerID)] = slot + } + } + return out +} + +func (a *App) readPositionBoards(players []Player, derived DerivedState) (map[string]map[int]PositionSlot, error) { + defaults := defaultPositionBoards(players, derived) + merged := map[string]map[int]PositionSlot{} + for board, boardMap := range defaults { + merged[board] = map[int]PositionSlot{} + for playerID, slot := range boardMap { + merged[board][playerID] = slot + } + } + + rows, err := a.db.Query(`SELECT board, player_id, group_key, position FROM player_positions`) + if err != nil { + return nil, fmt.Errorf("query player positions: %w", err) + } + defer rows.Close() + + for rows.Next() { + var boardRaw string + var playerID int + var groupKey string + var position int + if err := rows.Scan(&boardRaw, &playerID, &groupKey, &position); err != nil { + return nil, fmt.Errorf("scan player position: %w", err) + } + board, ok := normalizePositionBoard(boardRaw) + if !ok { + continue + } + boardMap := merged[board] + if boardMap == nil { + continue + } + defaultSlot, exists := boardMap[playerID] + if !exists { + continue + } + pos := normalizePositionValue(position, defaultSlot.Position) + group := normalizePositionGroupKey(board, groupKey, defaultSlot.GroupKey) + boardMap[playerID] = PositionSlot{ + GroupKey: group, + Position: pos, + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate player positions: %w", err) + } + + return merged, nil +} + +func defaultPositionBoards(players []Player, derived DerivedState) map[string]map[int]PositionSlot { + out := map[string]map[int]PositionSlot{ + positionBoardPreliminary: {}, + positionBoardFinal: {}, + positionBoardPrelimTie: {}, + positionBoardFinalTie: {}, + } + + preRows := make([]Player, 0, len(players)) + preRows = append(preRows, players...) + sort.SliceStable(preRows, func(i, j int) bool { + ga := normalizePreliminaryGroupKey(preRows[i].GroupCode) + gb := normalizePreliminaryGroupKey(preRows[j].GroupCode) + if ga != gb { + return ga < gb + } + return preRows[i].ID < preRows[j].ID + }) + preGroupCounts := map[string]int{} + for _, player := range preRows { + group := normalizePreliminaryGroupKey(player.GroupCode) + preGroupCounts[group]++ + out[positionBoardPreliminary][player.ID] = PositionSlot{ + GroupKey: group, + Position: preGroupCounts[group], + } + } + + finalGroupCounts := map[string]int{} + for _, row := range derived.FinalGroups.Group1 { + group := "1" + finalGroupCounts[group]++ + out[positionBoardFinal][row.PlayerID] = PositionSlot{GroupKey: group, Position: finalGroupCounts[group]} + } + for _, row := range derived.FinalGroups.Group2 { + group := "2" + finalGroupCounts[group]++ + out[positionBoardFinal][row.PlayerID] = PositionSlot{GroupKey: group, Position: finalGroupCounts[group]} + } + + preRowsByID := map[int]RankingRow{} + for _, row := range derived.PreliminaryRanking.Rows { + preRowsByID[row.PlayerID] = row + } + preTieRows := make([]RankingRow, 0, len(derived.PreliminaryRanking.TieBreak.PlayerIDs)) + for _, id := range derived.PreliminaryRanking.TieBreak.PlayerIDs { + row, ok := preRowsByID[id] + if !ok { + continue + } + preTieRows = append(preTieRows, row) + } + sort.SliceStable(preTieRows, func(i, j int) bool { + if preTieRows[i].TieBreak != preTieRows[j].TieBreak { + return preTieRows[i].TieBreak > preTieRows[j].TieBreak + } + if preTieRows[i].Score != preTieRows[j].Score { + return preTieRows[i].Score > preTieRows[j].Score + } + return preTieRows[i].PlayerID < preTieRows[j].PlayerID + }) + for idx, row := range preTieRows { + groupKey := strconv.Itoa((idx / 6) + 1) + position := (idx % 6) + 1 + out[positionBoardPrelimTie][row.PlayerID] = PositionSlot{GroupKey: groupKey, Position: position} + } + + finalRowsByID := map[int]RankingRow{} + for _, row := range derived.FinalRanking.Rows { + finalRowsByID[row.PlayerID] = row + } + finalTieRows := make([]RankingRow, 0, len(derived.FinalRanking.TieBreak.PlayerIDs)) + for _, id := range derived.FinalRanking.TieBreak.PlayerIDs { + row, ok := finalRowsByID[id] + if !ok { + continue + } + finalTieRows = append(finalTieRows, row) + } + sort.SliceStable(finalTieRows, func(i, j int) bool { + if finalTieRows[i].TieBreak != finalTieRows[j].TieBreak { + return finalTieRows[i].TieBreak > finalTieRows[j].TieBreak + } + if finalTieRows[i].Score != finalTieRows[j].Score { + return finalTieRows[i].Score > finalTieRows[j].Score + } + return finalTieRows[i].PlayerID < finalTieRows[j].PlayerID + }) + for idx, row := range finalTieRows { + groupKey := strconv.Itoa((idx / 6) + 1) + position := (idx % 6) + 1 + out[positionBoardFinalTie][row.PlayerID] = PositionSlot{GroupKey: groupKey, Position: position} + } + + return out +} + +func normalizePositionBoard(board string) (string, bool) { + normalized := strings.ToLower(strings.TrimSpace(board)) + if !allowedPositionBoards[normalized] { + return "", false + } + return normalized, true +} + +func normalizePreliminaryGroupKey(groupCode string) string { + normalized := strings.ToUpper(strings.TrimSpace(groupCode)) + if normalized == "" { + return positionBoardUnassigned + } + return normalized +} + +func normalizePositionGroupKey(board, groupKey, fallback string) string { + if board == positionBoardPreliminary { + normalized := normalizePreliminaryGroupKey(groupKey) + if normalized == "" { + return fallback + } + return normalized + } + normalized := strings.TrimSpace(groupKey) + if normalized == "" { + return fallback + } + return normalized +} + +func normalizePositionValue(position int, fallback int) int { + if position > 0 { + return position + } + if fallback > 0 { + return fallback + } + return 1 +} + func (a *App) ensureScoreRows(players []Player) error { if len(players) == 0 { return nil diff --git a/frontend/public/arture.png b/frontend/public/arture.png new file mode 100644 index 0000000..54ec553 Binary files /dev/null and b/frontend/public/arture.png differ diff --git a/frontend/public/arture_logo.png b/frontend/public/arture_logo.png deleted file mode 100644 index 98a6c0e..0000000 Binary files a/frontend/public/arture_logo.png and /dev/null differ diff --git a/frontend/public/logo_white.png b/frontend/public/logo_white.png new file mode 100644 index 0000000..fdd49f7 Binary files /dev/null and b/frontend/public/logo_white.png differ diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 08bf3d3..601c16a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,5 +1,5 @@ @@ -219,6 +233,7 @@ const FALLBACK_SYNC_MS = 15000 const STREAM_RETRY_MS = 1500 const PRELIM_ROUND_STAGES = ['prelim_r1', 'prelim_r2', 'prelim_r3'] const FINAL_ROUND_STAGES = ['final_r1', 'final_r2'] +const POSITION_BOARDS = ['preliminary', 'final', 'prelim_tiebreak', 'final_tiebreak'] const loading = ref(true) const mode = ref('live') @@ -247,6 +262,11 @@ const scoreFilters = reactive({ prelim_tiebreak: '', final_tiebreak: '', }) +const stageControl = reactive({ + phase: 'preliminary', + group: '', + round: 1, +}) const liveGroupIndex = ref(0) const liveMode = ref('rotate') @@ -298,6 +318,14 @@ const finalRows = computed(() => state.value.derived?.finalRanking?.rows || []) const podiumRows = computed(() => state.value.derived?.podium || []) const prelimTie = computed(() => state.value.derived?.preliminaryRanking?.tieBreak || { required: false, resolved: true, slots: 0, playerIds: [] }) const finalTie = computed(() => state.value.derived?.finalRanking?.tieBreak || { required: false, resolved: true, slots: 0, playerIds: [] }) +const positionBoards = computed(() => { + const raw = state.value.positionBoards || {} + const out = {} + for (const board of POSITION_BOARDS) { + out[board] = raw[board] || {} + } + return out +}) const canViewProofs = computed(() => Boolean(state.value.settings?.viewProofInView)) const liveSettings = computed(() => { const raw = state.value.settings?.liveMode || {} @@ -320,6 +348,10 @@ const liveSettings = computed(() => { rotationPlayerCount: Number(raw.rotationPlayerCount) > 0 ? Number(raw.rotationPlayerCount) : 12, } }) +const currentStage = computed(() => state.value.current_stage || null) +const lastStage = computed(() => state.value.last_stage || null) +const currentScoringStageKey = computed(() => mapStageSessionToScoreStage(currentStage.value)) +const currentScoringGroupKey = computed(() => mapStageSessionToScoreGroup(currentStage.value)) const viewTabs = computed(() => [ { id: 'groups', label: t('tabs.groups') }, @@ -409,7 +441,8 @@ const liveGroupCode = computed(() => { const liveMembers = computed(() => { const code = normalizedGroupCode(liveGroupCode.value) if (!code) return [] - return playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code) + const rows = playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code) + return sortRowsByPreliminaryPosition(rows) }) const liveMonitorGroupRotationCodes = computed(() => activeGroupCodes.value) @@ -424,7 +457,8 @@ const liveMonitorGroupCode = computed(() => { const liveMonitorMembers = computed(() => { const code = normalizedGroupCode(liveMonitorGroupCode.value) if (!code) return [] - return playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code) + const rows = playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code) + return sortRowsByPreliminaryPosition(rows) }) const liveMonitorFinalGroup = computed(() => { if (liveSettings.value.finalDisplayMode === 'fixed') { @@ -432,17 +466,32 @@ const liveMonitorFinalGroup = computed(() => { } return liveMonitorFinalIndex.value % 2 === 0 ? 1 : 2 }) -const liveMonitorFinalRows = computed(() => (liveMonitorFinalGroup.value === 2 ? finalGroup2.value : finalGroup1.value)) +const finalBoardGroupedRows = computed(() => { + const sorted = sortRowsByBoardPosition('final', adminFinalRows.value) + const grouped = {} + for (const row of sorted) { + const key = positionSlotFor('final', row.playerId)?.groupKey || String(row.finalGroup || 1) + if (!grouped[key]) grouped[key] = [] + grouped[key].push(row) + } + return grouped +}) +const liveMonitorFinalRows = computed(() => { + const key = String(liveMonitorFinalGroup.value === 2 ? 2 : 1) + return finalBoardGroupedRows.value[key] || [] +}) const podiumOrdered = computed(() => [podiumRows.value[1] || null, podiumRows.value[0] || null, podiumRows.value[2] || null]) const prelimTieRows = computed(() => { const ids = new Set(prelimTie.value.playerIds || []) - return preliminaryRows.value.filter((row) => ids.has(row.playerId)) + const rows = preliminaryRows.value.filter((row) => ids.has(row.playerId)) + return sortRowsByBoardPosition('prelim_tiebreak', rows) }) const finalTieRows = computed(() => { const ids = new Set(finalTie.value.playerIds || []) - return finalRows.value.filter((row) => ids.has(row.playerId)) + const rows = finalRows.value.filter((row) => ids.has(row.playerId)) + return sortRowsByBoardPosition('final_tiebreak', rows) }) const playerByID = computed(() => { @@ -483,8 +532,7 @@ const adminPreliminaryRows = computed(() => { rank: derived?.rank ?? 0, } }) - rows.sort(compareRowsByGroupThenId) - return rows + return sortRowsByBoardPosition('preliminary', rows) }) const adminPrelimTieRows = computed(() => { @@ -504,8 +552,7 @@ const adminPrelimTieRows = computed(() => { } }) .filter(Boolean) - rows.sort(compareRowsByScoreGroupThenId) - return rows + return sortRowsByBoardPosition('prelim_tiebreak', rows) }) const adminFinalRows = computed(() => { @@ -525,17 +572,7 @@ const adminFinalRows = computed(() => { } }) - rows.sort((a, b) => { - const groupA = Number(a.finalGroup || 0) - const groupB = Number(b.finalGroup || 0) - if (groupA !== groupB) return groupA - groupB - const seedA = Number(a.seed || 0) - const seedB = Number(b.seed || 0) - if (seedA !== seedB) return seedA - seedB - return a.playerId - b.playerId - }) - - return rows + return sortRowsByBoardPosition('final', rows) }) const adminFinalTieRows = computed(() => { @@ -558,8 +595,29 @@ const adminFinalTieRows = computed(() => { }) .filter(Boolean) - rows.sort(compareRowsByScoreGroupThenId) - return rows + return sortRowsByBoardPosition('final_tiebreak', rows) +}) + +const stageOptions = computed(() => { + const preliminaryGroups = collectUniqueStrings([ + ...assignableGroups.value, + ...activeGroupCodes.value, + ]) + const finalGroups = collectUniqueStrings([ + ...Object.keys(finalBoardGroupedRows.value || {}), + '1', + '2', + ]).filter((value) => value === '1' || value === '2') + + const prelimTieGroups = collectUniqueStrings((adminPrelimTieRows.value || []).map((row) => String(row._boardGroupKey || row.scoreGroup || '1'))) + const finalTieGroups = collectUniqueStrings((adminFinalTieRows.value || []).map((row) => String(row._boardGroupKey || row.scoreGroup || '1'))) + + return { + preliminary: preliminaryGroups, + prelim_tiebreak: prelimTieGroups.length > 0 ? prelimTieGroups : ['1'], + final: finalGroups.length > 0 ? finalGroups : ['1', '2'], + final_tiebreak: finalTieGroups.length > 0 ? finalTieGroups : ['1'], + } }) watch(language, (value) => { @@ -643,6 +701,14 @@ watch( { immediate: true }, ) +watch( + () => `${stageControl.phase}|${JSON.stringify(stageOptions.value)}`, + () => { + applyStageControlDefaults() + }, + { immediate: true }, +) + onMounted(async () => { onFullscreenChange() document.addEventListener('fullscreenchange', onFullscreenChange) @@ -728,7 +794,7 @@ async function toggleFullscreen() { function normalizeLiveActiveView(value) { const normalized = String(value || '').trim().toLowerCase() - const allowed = new Set(['group_live', 'prelim_tie', 'prelim_overall', 'final_groups', 'final_tie', 'podium']) + const allowed = new Set(['group_live', 'prelim_tie', 'prelim_overall', 'final_groups', 'final_tie', 'final_overall', 'podium']) return allowed.has(normalized) ? normalized : 'group_live' } @@ -788,6 +854,116 @@ function compareRowsByScoreGroupThenId(a, b) { return compareRowsByGroupThenId(a, b) } +function positionSlotFor(board, playerId) { + const boardMap = positionBoards.value?.[board] || {} + return boardMap[String(playerId)] || boardMap[playerId] || null +} + +function normalizePositionGroup(board, raw, fallback = '') { + if (board === 'preliminary') { + const normalized = normalizedGroupCode(raw || '') + if (normalized) return normalized + if (String(raw || '').trim().toUpperCase() === 'UNASSIGNED') return 'UNASSIGNED' + return fallback || 'UNASSIGNED' + } + const parsed = Number.parseInt(String(raw || ''), 10) + if (Number.isFinite(parsed) && parsed > 0) return String(parsed) + return fallback || '1' +} + +function normalizePositionValue(raw, fallback = 1) { + const parsed = Number(raw) + if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed) + return fallback +} + +function positionGroupSortValue(board, groupKey) { + if (board === 'preliminary') { + if (groupKey === 'UNASSIGNED') return Number.MAX_SAFE_INTEGER - 1 + const idx = assignableGroups.value.indexOf(groupKey) + if (idx >= 0) return idx + return Number.MAX_SAFE_INTEGER + } + return Number.parseInt(String(groupKey || '0'), 10) || Number.MAX_SAFE_INTEGER +} + +function sortRowsByPreliminaryPosition(rows) { + return sortRowsByBoardPosition('preliminary', rows) +} + +function sortRowsByBoardPosition(board, rows) { + const list = Array.isArray(rows) ? rows.slice() : [] + if (list.length <= 1) return list + + const fallbackRows = list.slice() + if (board === 'preliminary') { + fallbackRows.sort(compareRowsByGroupThenId) + } else if (board === 'final') { + fallbackRows.sort((a, b) => { + const groupA = Number(a.finalGroup || 0) + const groupB = Number(b.finalGroup || 0) + if (groupA !== groupB) return groupA - groupB + const seedA = Number(a.seed || 0) + const seedB = Number(b.seed || 0) + if (seedA !== seedB) return seedA - seedB + return Number(a.playerId || a.id || 0) - Number(b.playerId || b.id || 0) + }) + } else { + fallbackRows.sort(compareRowsByScoreGroupThenId) + } + + const fallbackMap = new Map() + const fallbackCountByGroup = new Map() + for (let index = 0; index < fallbackRows.length; index += 1) { + const row = fallbackRows[index] + const playerId = Number(row.playerId || row.id || 0) + let fallbackGroup = '1' + if (board === 'preliminary') { + fallbackGroup = normalizePositionGroup(board, row.groupCode, 'UNASSIGNED') + } else if (board === 'final') { + fallbackGroup = normalizePositionGroup(board, row.finalGroup, '1') + } else if (board === 'prelim_tiebreak' || board === 'final_tiebreak') { + const scoreGroup = Number.parseInt(String(row.scoreGroup || ''), 10) + const finalGroup = Number.parseInt(String(row.finalGroup || ''), 10) + if (Number.isFinite(scoreGroup) && scoreGroup > 0) { + fallbackGroup = String(scoreGroup) + } else if (Number.isFinite(finalGroup) && finalGroup > 0) { + fallbackGroup = String(finalGroup) + } else { + fallbackGroup = '1' + } + } else { + fallbackGroup = '1' + } + const nextPos = (fallbackCountByGroup.get(fallbackGroup) || 0) + 1 + fallbackCountByGroup.set(fallbackGroup, nextPos) + fallbackMap.set(playerId, { groupKey: fallbackGroup, position: nextPos, index }) + } + + return list + .map((row) => { + const playerId = Number(row.playerId || row.id || 0) + const fallback = fallbackMap.get(playerId) || { groupKey: '1', position: Number.MAX_SAFE_INTEGER, index: Number.MAX_SAFE_INTEGER } + const slot = positionSlotFor(board, playerId) + const groupKey = normalizePositionGroup(board, slot?.groupKey, fallback.groupKey) + const position = normalizePositionValue(slot?.position, fallback.position) + return { row, groupKey, position, fallbackIndex: fallback.index } + }) + .sort((a, b) => { + const groupOrderA = positionGroupSortValue(board, a.groupKey) + const groupOrderB = positionGroupSortValue(board, b.groupKey) + if (groupOrderA !== groupOrderB) return groupOrderA - groupOrderB + if (a.groupKey !== b.groupKey) return String(a.groupKey).localeCompare(String(b.groupKey)) + if (a.position !== b.position) return a.position - b.position + return a.fallbackIndex - b.fallbackIndex + }) + .map((entry) => ({ + ...entry.row, + _boardGroupKey: entry.groupKey, + _boardPosition: entry.position, + })) +} + function saveGroupSetup() { const parsed = parseGroupList(groupSetupInput.value) primaryGroups.value = parsed.length > 0 ? parsed : DEFAULT_PRIMARY_GROUPS @@ -829,6 +1005,22 @@ function updateScoreFilter({ stage, value }) { scoreFilters[stage] = value } +async function savePositionBoard(payload) { + const board = String(payload?.board || '').trim().toLowerCase() + if (!POSITION_BOARDS.includes(board)) return + const slots = Array.isArray(payload?.slots) ? payload.slots : [] + + try { + state.value = await api(`/admin/positions/${board}`, { + method: 'PUT', + admin: true, + body: { slots }, + }) + } catch (error) { + showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error') + } +} + async function autoGroupEvenly() { if (primaryGroups.value.length === 0) { showToast(t('messages.noGroupsConfigured'), 'error') @@ -990,6 +1182,139 @@ async function updateLiveModeSetting(patch) { await updateAdminSettings({ liveMode: patch }) } +function collectUniqueStrings(values) { + const set = new Set() + for (const raw of values || []) { + const value = String(raw || '').trim() + if (!value) continue + set.add(value) + } + return [...set].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })) +} + +function stageRoundMax(phase) { + if (phase === 'preliminary') return 3 + if (phase === 'final') return 2 + return 1 +} + +function mapStageSessionToScoreStage(stage) { + if (!stage || typeof stage !== 'object') return '' + const phase = String(stage.phase || '').trim().toLowerCase() + const round = Number(stage.round || 1) + if (phase === 'preliminary') return `prelim_r${Math.min(3, Math.max(1, Math.floor(round || 1)))}` + if (phase === 'final') return `final_r${Math.min(2, Math.max(1, Math.floor(round || 1)))}` + if (phase === 'prelim_tiebreak') return 'prelim_tiebreak' + if (phase === 'final_tiebreak') return 'final_tiebreak' + return '' +} + +function mapStageSessionToScoreGroup(stage) { + if (!stage || typeof stage !== 'object') return '' + const phase = String(stage.phase || '').trim().toLowerCase() + const rawGroup = String(stage.group || '').trim() + if (!rawGroup) return '' + if (phase === 'preliminary') { + if (rawGroup.toUpperCase() === 'UNASSIGNED') return 'UNASSIGNED' + return normalizedGroupCode(rawGroup) + } + const parsed = Number.parseInt(rawGroup, 10) + if (Number.isFinite(parsed) && parsed > 0) return String(parsed) + return rawGroup +} + +function mapStagePhaseToAdminTab(phase) { + const normalized = String(phase || '').trim().toLowerCase() + if (normalized === 'preliminary') return 'preliminary' + if (normalized === 'prelim_tiebreak') return 'prelimTie' + if (normalized === 'final') return 'final' + if (normalized === 'final_tiebreak') return 'finalTie' + return '' +} + +function applyStageControlDefaults() { + const phase = String(stageControl.phase || 'preliminary').trim().toLowerCase() + if (!['preliminary', 'prelim_tiebreak', 'final', 'final_tiebreak'].includes(phase)) { + stageControl.phase = 'preliminary' + } else { + stageControl.phase = phase + } + + const maxRound = stageRoundMax(stageControl.phase) + const round = Number(stageControl.round || 1) + stageControl.round = Math.min(maxRound, Math.max(1, Number.isFinite(round) ? Math.floor(round) : 1)) + + const options = stageOptions.value?.[stageControl.phase] || [] + if (options.length === 0) { + stageControl.group = '' + return + } + const current = String(stageControl.group || '').trim() + if (!options.includes(current)) { + stageControl.group = options[0] + } +} + +function updateStageControl(patch) { + if (!patch || typeof patch !== 'object') return + if (Object.prototype.hasOwnProperty.call(patch, 'phase')) { + stageControl.phase = String(patch.phase || 'preliminary').trim().toLowerCase() + } + if (Object.prototype.hasOwnProperty.call(patch, 'group')) { + stageControl.group = String(patch.group || '').trim() + } + if (Object.prototype.hasOwnProperty.call(patch, 'round')) { + stageControl.round = Number(patch.round || 1) + } + applyStageControlDefaults() +} + +async function startCurrentStage() { + applyStageControlDefaults() + if (!stageControl.group) { + showToast(t('messages.noGroupForPhase'), 'error') + return + } + try { + state.value = await api('/admin/stage/start', { + method: 'POST', + admin: true, + body: { + phase: stageControl.phase, + group: stageControl.group, + round: stageControl.round, + }, + }) + showToast(t('messages.saved')) + } catch (error) { + showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error') + } +} + +async function endCurrentStage() { + try { + state.value = await api('/admin/stage/end', { + method: 'POST', + admin: true, + }) + showToast(t('messages.saved')) + } catch (error) { + showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error') + } +} + +function navigateToCurrentStage() { + const active = currentStage.value + if (!active) { + showToast(t('labels.noCurrentStage'), 'error') + return + } + const nextTab = mapStagePhaseToAdminTab(active.phase) + if (nextTab) { + adminTab.value = nextTab + } +} + async function createPlayer() { if (!newPlayer.nameAr || !newPlayer.nameEn) { showToast(t('messages.mustProvideNames'), 'error') diff --git a/frontend/src/components/AdminPanel.vue b/frontend/src/components/AdminPanel.vue index 52ecb4b..d2e9fd1 100644 --- a/frontend/src/components/AdminPanel.vue +++ b/frontend/src/components/AdminPanel.vue @@ -59,7 +59,20 @@

{{ t('liveMode') }}

{{ t('labels.liveModeVisibility') }}

- +
@@ -81,6 +94,18 @@ /> + + {{ t('actions.resetScores') }} + + @@ -174,6 +219,18 @@ /> + + {{ t('actions.resetScores') }} + + @@ -287,6 +364,7 @@ import PlayersManagementTab from './admin/PlayersManagementTab.vue' import ScoreStageEditor from './admin/ScoreStageEditor.vue' import LiveSettingsCard from './admin/LiveSettingsCard.vue' import MultiRoundScoreEditor from './admin/MultiRoundScoreEditor.vue' +import PositionBoardEditor from './admin/PositionBoardEditor.vue' defineProps({ t: { type: Function, required: true }, @@ -294,6 +372,12 @@ defineProps({ adminTab: { type: String, required: true }, viewProofInView: { type: Boolean, default: false }, liveSettings: { type: Object, required: true }, + stageControl: { type: Object, required: true }, + stageOptions: { type: Object, required: true }, + currentStage: { type: Object, default: null }, + lastStage: { type: Object, default: null }, + activeScoringStage: { type: String, default: '' }, + activeScoringGroup: { type: String, default: '' }, groupSetupInput: { type: String, default: '' }, adminGroupCards: { type: Array, required: true }, newPlayer: { type: Object, required: true }, @@ -313,6 +397,7 @@ defineProps({ finalTotalScore: { type: Function, required: true }, prelimTie: { type: Object, required: true }, finalTie: { type: Object, required: true }, + positionBoards: { type: Object, required: true }, scoreFilters: { type: Object, required: true }, displayName: { type: Function, required: true }, secondaryName: { type: Function, required: true }, @@ -334,6 +419,10 @@ defineEmits([ 'logout', 'toggle-view-proof', 'update-live-setting', + 'update-stage-control', + 'start-stage', + 'end-stage', + 'navigate-current-stage', 'change-admin-tab', 'update:group-setup-input', 'save-group-setup', @@ -350,6 +439,7 @@ defineEmits([ 'remove-player-image', 'delete-player', 'request-reset-stage', + 'save-position-board', 'update-score-filter', ]) diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 5c555a3..be4480e 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -3,8 +3,8 @@
- - + +

{{ t('subtitle') }}

@@ -52,7 +52,7 @@

{{ t('labels.mode') }}

- +
diff --git a/frontend/src/components/LiveModePanel.vue b/frontend/src/components/LiveModePanel.vue index 2fbe4ae..580d79a 100644 --- a/frontend/src/components/LiveModePanel.vue +++ b/frontend/src/components/LiveModePanel.vue @@ -31,7 +31,7 @@ - + @@ -40,8 +40,8 @@ - - + +
{{ t('table.rank') }}{{ t('table.position') }} {{ t('table.competitor') }} {{ t('table.round1') }} {{ t('table.round2') }}
{{ entry.rank }}
{{ index + 1 }}
@@ -69,41 +69,42 @@

{{ t('sections.prelimTieTitle') }}

{{ t('table.tieScore') }} - {{ currentListPageDisplay }} + {{ currentTieGroupPageDisplay }}
-
+
+

{{ t('labels.group') }} {{ activePrelimTieGroup.groupKey }}

- + - - + + - + -
#{{ t('table.position') }} {{ t('table.competitor') }} {{ t('table.tieScore') }}
{{ listPageStart + index + 1 }}
{{ entry.position }}
- +
-

{{ displayName(row) }}

-

{{ secondaryName(row) }}

+

{{ displayName(entry.row) }}

+

{{ secondaryName(entry.row) }}

{{ row.tieBreak }}{{ entry.row.tieBreak }}
{{ t('messages.noPrelimTie') }}
-
+
+
{{ t('messages.noPrelimTie') }}
@@ -173,7 +174,7 @@ - + @@ -181,8 +182,8 @@ - - + +
{{ t('table.rank') }}{{ t('table.position') }} {{ t('table.competitor') }} {{ t('table.round1') }} {{ t('table.round2') }}
{{ entry.rank }}
{{ index + 1 }}
@@ -209,24 +210,67 @@

{{ t('sections.finalTieTitle') }}

{{ t('table.tieScore') }} - {{ currentListPageDisplay }} + {{ currentTieGroupPageDisplay }}
-
+
+

{{ t('labels.group') }} {{ activeFinalTieGroup.groupKey }}

- + - - + + + + + + +
#{{ t('table.position') }} {{ t('table.competitor') }} {{ t('table.tieScore') }}
{{ listPageStart + index + 1 }}
{{ entry.position }} +
+ +
+

{{ displayName(entry.row) }}

+

{{ secondaryName(entry.row) }}

+
+
+
{{ entry.row.tieBreak }}
+
+
+ +
{{ t('messages.noFinalTie') }}
+ + +
+
+

{{ t('sections.finalRanking') }}

+
+ {{ currentListPageDisplay }} +
+
+ + +
+
+ + + + + + + + + + + + - + + - +
{{ t('table.rank') }}{{ t('table.competitor') }}{{ t('table.total') }}{{ t('table.tieScore') }}
{{ row.rank }}
@@ -236,9 +280,10 @@
{{ row.tieBreak }}{{ row.score }}{{ row.tieBreak }}
{{ t('messages.noFinalTie') }}
{{ t('labels.noFinalists') }}
@@ -291,20 +336,23 @@
+ +
+ + + + + + + +
- -
- - - - -
@@ -314,6 +362,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue' const props = defineProps({ t: { type: Function, required: true }, liveSettings: { type: Object, required: true }, + positionBoards: { type: Object, default: () => ({}) }, liveGroupCode: { type: String, default: '' }, liveGroupMembers: { type: Array, required: true }, prelimTieRows: { type: Array, required: true }, @@ -321,6 +370,7 @@ const props = defineProps({ finalists: { type: Array, required: true }, liveFinalGroup: { type: Number, required: true }, liveFinalRows: { type: Array, required: true }, + finalOverallRows: { type: Array, required: true }, finalTieRows: { type: Array, required: true }, podiumOrdered: { type: Array, required: true }, playerImage: { type: Function, required: true }, @@ -339,7 +389,9 @@ const props = defineProps({ const finalistIds = computed(() => new Set((props.finalists || []).map((row) => row.playerId))) const activeView = computed(() => props.liveSettings.activeView || 'group_live') const listPageIndex = ref(0) +const tieGroupIndex = ref(0) let listRotationTimer = null +let tieRotationTimer = null const rotationIntervalSec = computed(() => { const raw = Number(props.liveSettings.rotationIntervalSec || 5) @@ -356,9 +408,8 @@ const rotationPlayerCount = computed(() => { }) const listRowsForActiveView = computed(() => { - if (activeView.value === 'prelim_tie') return props.prelimTieRows || [] if (activeView.value === 'prelim_overall') return props.preliminaryRows || [] - if (activeView.value === 'final_tie') return props.finalTieRows || [] + if (activeView.value === 'final_overall') return props.finalOverallRows || [] return [] }) @@ -368,30 +419,37 @@ const listPageCount = computed(() => { return Math.max(1, Math.ceil(total / rotationPlayerCount.value)) }) -const listPageStart = computed(() => listPageIndex.value * rotationPlayerCount.value) const currentListPageDisplay = computed(() => `${listPageIndex.value + 1} / ${listPageCount.value}`) -const pagedPrelimTieRows = computed(() => paginateRows(props.prelimTieRows || [])) const pagedPreliminaryRows = computed(() => paginateRows(props.preliminaryRows || [])) -const pagedFinalTieRows = computed(() => paginateRows(props.finalTieRows || [])) +const pagedFinalOverallRows = computed(() => paginateRows(props.finalOverallRows || [])) +const groupedPrelimTieRows = computed(() => groupTieRowsByPositionBoard('prelim_tiebreak', props.prelimTieRows || [])) +const groupedFinalTieRows = computed(() => groupTieRowsByPositionBoard('final_tiebreak', props.finalTieRows || [])) +const tieGroupCountForActiveView = computed(() => { + if (activeView.value === 'prelim_tie') return groupedPrelimTieRows.value.length + if (activeView.value === 'final_tie') return groupedFinalTieRows.value.length + return 0 +}) +const currentTieGroupPageDisplay = computed(() => { + const total = tieGroupCountForActiveView.value + if (total <= 0) return '0 / 0' + return `${(tieGroupIndex.value % total) + 1} / ${total}` +}) +const activePrelimTieGroup = computed(() => { + const groups = groupedPrelimTieRows.value + if (groups.length === 0) return null + return groups[tieGroupIndex.value % groups.length] +}) +const activeFinalTieGroup = computed(() => { + const groups = groupedFinalTieRows.value + if (groups.length === 0) return null + return groups[tieGroupIndex.value % groups.length] +}) const rankedLiveGroupMembers = computed(() => { - const rows = (props.liveGroupMembers || []).map((player) => ({ + return (props.liveGroupMembers || []).map((player) => ({ player, total: Number(props.prelimTotal(player.id) || 0), })) - rows.sort((a, b) => { - if (a.total !== b.total) return b.total - a.total - return a.player.id - b.player.id - }) - - let prevTotal = null - let prevRank = 0 - return rows.map((entry, idx) => { - const rank = prevTotal === entry.total ? prevRank : idx + 1 - prevTotal = entry.total - prevRank = rank - return { ...entry, rank } - }) }) const activeViewLabel = computed(() => { @@ -401,6 +459,7 @@ const activeViewLabel = computed(() => { prelim_overall: props.t('labels.liveViewPrelimOverall'), final_groups: props.t('labels.liveViewFinalGroups'), final_tie: props.t('labels.liveViewFinalTie'), + final_overall: props.t('labels.liveViewFinalOverall'), podium: props.t('labels.liveViewPodium'), } return map[activeView.value] || map.group_live @@ -419,27 +478,10 @@ const groupClassKey = computed(() => { return 'u' }) const rankedLiveFinalRows = computed(() => { - const rows = (props.liveFinalRows || []).map((row) => ({ + return (props.liveFinalRows || []).map((row) => ({ row, total: Number(props.finalTotal(row.playerId) || 0), })) - - rows.sort((a, b) => { - if (a.total !== b.total) return b.total - a.total - const seedA = Number(a.row.seed || 0) - const seedB = Number(b.row.seed || 0) - if (seedA !== seedB) return seedA - seedB - return a.row.playerId - b.row.playerId - }) - - let prevTotal = null - let prevRank = 0 - return rows.map((entry, idx) => { - const rank = prevTotal === entry.total ? prevRank : idx + 1 - prevTotal = entry.total - prevRank = rank - return { ...entry, rank } - }) }) function isFinalist(playerId) { @@ -448,15 +490,17 @@ function isFinalist(playerId) { watch( () => - `${activeView.value}|${rotationIntervalSec.value}|${rotationPlayerCount.value}|${props.prelimTieRows.length}|${props.preliminaryRows.length}|${props.finalTieRows.length}`, + `${activeView.value}|${rotationIntervalSec.value}|${rotationPlayerCount.value}|${props.prelimTieRows.length}|${props.preliminaryRows.length}|${props.finalOverallRows.length}|${props.finalTieRows.length}`, () => { restartListRotation() + restartTieRotation() }, { immediate: true }, ) onBeforeUnmount(() => { stopListRotation() + stopTieRotation() }) function paginateRows(rows) { @@ -468,7 +512,7 @@ function restartListRotation() { stopListRotation() listPageIndex.value = 0 - const autoRotateView = activeView.value === 'prelim_tie' || activeView.value === 'prelim_overall' || activeView.value === 'final_tie' + const autoRotateView = activeView.value === 'prelim_overall' || activeView.value === 'final_overall' if (!autoRotateView) return if (listPageCount.value <= 1) return @@ -487,4 +531,62 @@ function stopListRotation() { listRotationTimer = null } } + +function restartTieRotation() { + stopTieRotation() + tieGroupIndex.value = 0 + + const total = tieGroupCountForActiveView.value + if (total <= 1) return + if (activeView.value !== 'prelim_tie' && activeView.value !== 'final_tie') return + + tieRotationTimer = setInterval(() => { + if (tieGroupCountForActiveView.value <= 1) { + tieGroupIndex.value = 0 + return + } + tieGroupIndex.value = (tieGroupIndex.value + 1) % tieGroupCountForActiveView.value + }, rotationIntervalSec.value * 1000) +} + +function stopTieRotation() { + if (tieRotationTimer) { + clearInterval(tieRotationTimer) + tieRotationTimer = null + } +} + +function boardSlot(board, playerId) { + const boardMap = props.positionBoards?.[board] || {} + return boardMap[String(playerId)] || boardMap[playerId] || null +} + +function groupTieRowsByPositionBoard(board, rows) { + const mapped = (rows || []).map((row, index) => { + const slot = boardSlot(board, row.playerId) + const fallbackGroup = String(Math.floor(index / 6) + 1) + const fallbackPos = (index % 6) + 1 + const groupKeyRaw = String(slot?.groupKey || fallbackGroup).trim() + const parsedGroup = Number.parseInt(groupKeyRaw, 10) + const groupKey = Number.isFinite(parsedGroup) && parsedGroup > 0 ? String(parsedGroup) : fallbackGroup + const position = Number(slot?.position) > 0 ? Math.floor(Number(slot.position)) : fallbackPos + return { row, groupKey, position } + }) + + const groups = new Map() + for (const entry of mapped) { + if (!groups.has(entry.groupKey)) groups.set(entry.groupKey, []) + groups.get(entry.groupKey).push(entry) + } + + return [...groups.entries()] + .sort((a, b) => Number(a[0]) - Number(b[0])) + .map(([groupKey, items]) => ({ + groupKey, + rows: items.sort((a, b) => { + if (a.position !== b.position) return a.position - b.position + return a.row.playerId - b.row.playerId + }), + })) +} diff --git a/frontend/src/components/admin/LiveSettingsCard.vue b/frontend/src/components/admin/LiveSettingsCard.vue index b004ab5..4aa6c49 100644 --- a/frontend/src/components/admin/LiveSettingsCard.vue +++ b/frontend/src/components/admin/LiveSettingsCard.vue @@ -12,6 +12,7 @@ + @@ -77,19 +78,126 @@ @change="emitPatch({ rotationPlayerCount: Number($event.target.value) || 12 })" /> + +
+ +
+ +
+
+

{{ t('labels.stagePhase') }}

+ +
+
+

{{ t('labels.stageGroup') }}

+ +
+
+

{{ t('labels.stageRound') }}

+ +
+
+

{{ selectedStageSummary }}

+
+ + +
+
+ +
+
+

{{ t('labels.currentStage') }}

+ +

{{ t('labels.noCurrentStage') }}

+
+
+

{{ t('labels.lastStage') }}

+ +

{{ t('labels.noLastStage') }}

+
+
diff --git a/frontend/src/components/admin/MultiRoundScoreEditor.vue b/frontend/src/components/admin/MultiRoundScoreEditor.vue index a68523f..f32bd47 100644 --- a/frontend/src/components/admin/MultiRoundScoreEditor.vue +++ b/frontend/src/components/admin/MultiRoundScoreEditor.vue @@ -9,24 +9,102 @@ /> -
- - - - - - - - - - - - - - - - -
#{{ t('table.competitor') }}{{ t('table.group') }}{{ t('table.rank') }}{{ t('table.seed') }}{{ round.label }}{{ t('table.total') }}{{ t('table.verification') }}
{{ index + 1 }} +
+ +
{{ section.label }} > {{ overflowLimit }}
+ +
-
-
-
-
- -
-

{{ displayName(row) }}

-

{{ secondaryName(row) }}

-
-
-
#{{ index + 1 }}
-
- -
- {{ t('table.group') }}: {{ groupCell(row) }} - {{ t('table.rank') }}: {{ row.rank }} - {{ t('table.seed') }}: {{ row.seed }} -
- -
- -
- -
- {{ t('table.total') }} - {{ totalScore(row.playerId) }} -
- -
-

{{ t('table.verification') }}

-
-
- {{ round.label }} - - -
-
-
-
-
{{ t('labels.noPlayers') }}
-
+
{{ t('labels.noPlayers') }}
diff --git a/frontend/src/components/admin/PlayersManagementTab.vue b/frontend/src/components/admin/PlayersManagementTab.vue index ab9f0d3..9ced54e 100644 --- a/frontend/src/components/admin/PlayersManagementTab.vue +++ b/frontend/src/components/admin/PlayersManagementTab.vue @@ -83,10 +83,15 @@

{{ group.label }}

- {{ group.players.length }} +
+ {{ group.players.length }} + +
-
+
@@ -131,14 +136,14 @@
-
{{ t('labels.noPlayers') }}
+
{{ t('labels.noPlayers') }}
diff --git a/frontend/src/components/admin/ScoreStageEditor.vue b/frontend/src/components/admin/ScoreStageEditor.vue index 9fec1ff..6f4590b 100644 --- a/frontend/src/components/admin/ScoreStageEditor.vue +++ b/frontend/src/components/admin/ScoreStageEditor.vue @@ -9,24 +9,94 @@ /> -
- - - - - - - - - - - - - - - - -
#{{ t('table.competitor') }}{{ t('table.group') }}{{ t('table.score') }}{{ inputLabel }}{{ t('table.verification') }}{{ t('table.rank') }}{{ t('table.seed') }}
{{ index + 1 }} +
+ + +
+ +
{{ t('labels.noPlayers') }}
diff --git a/frontend/src/constants/i18n.js b/frontend/src/constants/i18n.js index b1fa4f3..a10fa2d 100644 --- a/frontend/src/constants/i18n.js +++ b/frontend/src/constants/i18n.js @@ -41,6 +41,7 @@ export const I18N = { liveViewPrelimOverall: 'الترتيب العام للتمهيدي', liveViewFinalGroups: 'شاشة مجموعات النهائي', liveViewFinalTie: 'كسر تعادل النهائي', + liveViewFinalOverall: 'الترتيب العام للنهائي', liveViewPodium: 'منصة التتويج', showGroupLive: 'إظهار شاشة المجموعات الحية', showPrelimTie: 'إظهار كسر تعادل التمهيدي', @@ -57,6 +58,21 @@ export const I18N = { currentScore: 'النتيجة الحالية', aiSuggestedScore: 'النتيجة المقترحة', confidence: 'مستوى الثقة', + stageControl: 'المرحلة الحالية', + stagePhase: 'الطور', + stageGroup: 'المجموعة', + stageRound: 'الجولة', + currentStage: 'المرحلة الجارية', + lastStage: 'آخر مرحلة', + startedAt: 'بدأت', + endedAt: 'انتهت', + noCurrentStage: 'لا توجد مرحلة جارية', + noLastStage: 'لا توجد مرحلة سابقة', + phasePreliminary: 'التمهيدي', + phasePrelimTie: 'كسر تعادل التمهيدي', + phaseFinal: 'النهائي', + phaseFinalTie: 'كسر تعادل النهائي', + groupAssignment: 'توزيع المجموعات', }, sections: { groupsTitle: 'عرض اللاعبين والمجموعات', @@ -93,6 +109,7 @@ export const I18N = { competitor: 'اللاعب', group: 'المجموعة', rank: 'الترتيب', + position: 'الموضع', score: 'النتيجة', total: 'المجموع', round1: 'جولة 1', @@ -142,6 +159,9 @@ export const I18N = { liveModeFixed: 'ثابت', enterFullscreen: 'دخول ملء الشاشة', exitFullscreen: 'الخروج من ملء الشاشة', + startStage: 'بدء المرحلة', + endStage: 'إنهاء المرحلة', + goToStageScoring: 'الانتقال إلى إدخال نتائج المرحلة', }, auth: { username: 'اسم المستخدم', @@ -176,6 +196,7 @@ export const I18N = { noNameToConvert: 'لا يوجد اسم للتحويل.', aiAnalyzing: 'جاري تحليل الصورة بالذكاء الاصطناعي...', noProofForAi: 'لا توجد صورة إثبات لتحليلها.', + noGroupForPhase: 'لا توجد مجموعات متاحة لهذا الطور.', }, }, en: { @@ -220,6 +241,7 @@ export const I18N = { liveViewPrelimOverall: 'Preliminary overall', liveViewFinalGroups: 'Final groups board', liveViewFinalTie: 'Final tie-break', + liveViewFinalOverall: 'Final overall ranking', liveViewPodium: 'Podium', showGroupLive: 'Show live group board', showPrelimTie: 'Show preliminary tie-break', @@ -236,6 +258,21 @@ export const I18N = { currentScore: 'Current score', aiSuggestedScore: 'AI suggested score', confidence: 'Confidence', + stageControl: 'Current Stage Control', + stagePhase: 'Phase', + stageGroup: 'Group', + stageRound: 'Round', + currentStage: 'Current Stage', + lastStage: 'Last Stage', + startedAt: 'Started', + endedAt: 'Ended', + noCurrentStage: 'No active stage', + noLastStage: 'No previous stage', + phasePreliminary: 'Preliminary', + phasePrelimTie: 'Preliminary Tie-Break', + phaseFinal: 'Final', + phaseFinalTie: 'Final Tie-Break', + groupAssignment: 'Group Assignment', }, sections: { groupsTitle: 'Players & Groups Overview', @@ -272,6 +309,7 @@ export const I18N = { competitor: 'Player', group: 'Group', rank: 'Rank', + position: 'Position', score: 'Score', total: 'Total', round1: 'Round 1', @@ -321,6 +359,9 @@ export const I18N = { liveModeFixed: 'Fixed', enterFullscreen: 'Enter Fullscreen', exitFullscreen: 'Exit Fullscreen', + startStage: 'Start Stage', + endStage: 'End Stage', + goToStageScoring: 'Go To Stage Scoring', }, auth: { username: 'Username', @@ -355,6 +396,7 @@ export const I18N = { noNameToConvert: 'No name available to convert.', aiAnalyzing: 'Analyzing image with AI...', noProofForAi: 'No proof image available to analyze.', + noGroupForPhase: 'No groups available for this phase.', }, }, } @@ -398,6 +440,8 @@ export function createInitialState() { finalRanking: { rows: [], tieBreak: { required: false, resolved: true, slots: 0, playerIds: [] } }, podium: [], }, + current_stage: null, + last_stage: null, serverTime: '', } } diff --git a/frontend/src/style.css b/frontend/src/style.css index e0c0588..3b7a23c 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -43,6 +43,30 @@ body { min-height: 100vh; } +.app-shell.mode-live { + background: #0d1931; +} + +.app-shell.mode-live .masthead { + background: #0d1931; + border-bottom: 1px solid rgba(0, 140, 168, 0.45); + box-shadow: none; +} + +.app-shell.mode-live .masthead::after { + height: 3px; + background: #ed2e23; +} + +.app-shell.mode-live .masthead-title, +.app-shell.mode-live .masthead-subtitle { + color: #ffffff; +} + +.app-shell.mode-live .page-content { + padding-top: 18px; +} + .hidden-file-input { display: none; } @@ -387,6 +411,107 @@ body { gap: 8px; } +.live-settings-divider { + border: 0; + border-top: 1px dashed #d4deef; + margin: 14px 0 10px; +} + +.stage-control-grid { + grid-template-columns: 1.1fr 1fr 0.8fr; + display: grid; + gap: 8px; +} + +.stage-select-card { + border: 1px solid #d4deef; + border-radius: 10px; + background: #fff; + padding: 8px; +} + +.stage-select-label { + margin: 0 0 6px; + font-size: 11px; + letter-spacing: 0.5px; + text-transform: uppercase; + color: #5d6d8e; + font-weight: 800; +} + +.stage-selection-preview { + margin: 8px 0 0; + padding: 8px 10px; + border-radius: 9px; + background: #f4f8ff; + color: #244372; + font-size: 12px; + font-weight: 700; +} + +.stage-action-row { + margin-top: 10px; +} + +.stage-start-btn { + min-width: 158px; + box-shadow: 0 8px 18px rgba(0, 140, 168, 0.26); +} + +.stage-start-btn:disabled { + background: #e7edf8; + color: #6e7f9f; + border-color: #d0d9ea; + box-shadow: none; +} + +.stage-status-grid { + margin-top: 8px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.stage-status-card { + border: 1px solid #d4deef; + border-radius: 10px; + padding: 10px; + background: #fff; +} + +.stage-status-title { + margin: 0; + font-size: 12px; + color: var(--muted); + font-weight: 700; +} + +.stage-status-main { + margin: 6px 0 0; + font-size: 13px; + font-weight: 800; + color: var(--navy); +} + +.stage-status-sub { + margin: 4px 0 0; + font-size: 11px; + color: #5c6a86; + font-family: "IBM Plex Mono", monospace; +} + +.stage-status-empty { + margin: 6px 0 0; + font-size: 13px; + color: var(--muted); + font-weight: 700; +} + +.stage-status-actions { + margin-top: 8px; + margin-bottom: 0; +} + .switch-row { display: inline-flex; align-items: center; @@ -816,6 +941,24 @@ body { font-weight: 800; } +.group-card-head-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.group-card-toggle { + border: 1px solid #d5deef; + background: #fff; + color: #2f4063; + border-radius: 8px; + min-width: 28px; + height: 28px; + font-weight: 800; + line-height: 1; + cursor: pointer; +} + .pm-count-badge { display: inline-flex; align-items: center; @@ -939,6 +1082,17 @@ select.name-input { animation: score-over-limit-blink 0.7s ease-in-out infinite; } +.stage-column-highlight { + background: linear-gradient(180deg, rgba(0, 140, 168, 0.08) 0%, rgba(13, 25, 49, 0.04) 100%); + border-top: 2px solid rgba(0, 140, 168, 0.35); +} + +.score-input-stage-highlight { + border-color: #0d8a64; + background: linear-gradient(180deg, #effaf6 0%, #e8f6ff 100%); + box-shadow: inset 0 0 0 1px rgba(13, 138, 100, 0.18); +} + @keyframes score-over-limit-blink { 0%, 100% { @@ -1049,7 +1203,7 @@ select.name-input { } .live-panel { - background: radial-gradient(circle at 15% 10%, #1a2653 0%, #101736 55%, #0b1026 100%); + background: #0d1931; color: #fff; min-height: 70vh; position: relative; @@ -1067,7 +1221,7 @@ select.name-input { text-align: center; font-size: 30px; font-weight: 800; - color: #f1cf7a; + color: #ffffff; } .live-subtitle { @@ -1156,7 +1310,7 @@ select.name-input { height: 5px; width: 100%; border-radius: 999px; - background: linear-gradient(90deg, var(--red), #f66f67); + background: #ed2e23; animation: progress-5s linear; } @@ -1173,9 +1327,10 @@ select.name-input { .live-mode-root { position: relative; - --live-footer-height: 74px; - --live-footer-offset: 16px; - padding-bottom: calc(var(--live-footer-height) + var(--live-footer-offset) + env(safe-area-inset-bottom)); + --live-navy: #0d1931; + --live-blue: #008ca8; + --live-red: #ed2e23; + --live-white: #ffffff; } .live-mode-shell { @@ -1183,29 +1338,30 @@ select.name-input { min-height: 72vh; border-radius: 18px; overflow: hidden; - background-size: contain; - background-position: center; - background-repeat: no-repeat; - border: 1px solid rgba(255, 255, 255, 0.25); - box-shadow: 0 16px 38px rgba(7, 19, 42, 0.24); + display: grid; + grid-template-rows: 1fr auto; + border: 1px solid rgba(0, 140, 168, 0.5); + box-shadow: 0 22px 44px rgba(0, 0, 0, 0.45); + background: var(--live-navy); } .live-mode-overlay { min-height: 72vh; - padding: 16px; - background: #171b3c; + padding: 16px 16px 12px; + background: var(--live-navy); } .live-mode-header h2 { margin: 0; - color: rgba(240, 248, 255, 0.84); + color: var(--live-white); font-size: clamp(22px, 3vw, 34px); + letter-spacing: 0.2px; } .live-mode-header p { margin: 6px 0 12px; - color: rgba(240, 248, 255, 0.84); - font-weight: 600; + color: rgba(255, 255, 255, 0.78); + font-weight: 500; } .live-mode-grid { @@ -1219,11 +1375,10 @@ select.name-input { } .live-screen-card { - border: 1px solid rgba(200, 223, 255, 0.28); + border: 1px solid rgba(0, 140, 168, 0.45); border-radius: 14px; - background: #1a1e3f; - box-shadow: 0 8px 18px rgba(7, 19, 42, 0.18); - backdrop-filter: blur(3px); + background: #0d1931; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2); padding: 10px; animation: fade-up 0.32s ease both; } @@ -1238,9 +1393,9 @@ select.name-input { } .live-focus-banner { - border: 1px solid rgba(168, 205, 242, 0.4); + border: 1px solid rgba(0, 140, 168, 0.45); border-radius: 14px; - background: rgba(23, 27, 60, 0.9); + background: #0d1931; padding: 10px 14px; display: grid; gap: 2px; @@ -1252,7 +1407,7 @@ select.name-input { font-size: 11px; font-weight: 800; letter-spacing: 0.9px; - color: rgba(201, 224, 248, 0.82); + color: rgba(255, 255, 255, 0.75); text-transform: uppercase; } @@ -1261,8 +1416,8 @@ select.name-input { font-size: clamp(34px, 6vw, 58px); line-height: 0.95; letter-spacing: 1.2px; - color: #f6d68a; - text-shadow: 0 3px 10px rgba(0, 0, 0, 0.35); + color: #ffffff; + text-shadow: 0 3px 12px rgba(0, 0, 0, 0.4); } .live-focus-banner.focus-a .live-focus-title, @@ -1271,7 +1426,7 @@ select.name-input { .live-focus-banner.focus-d .live-focus-title, .live-focus-banner.focus-u .live-focus-title, .live-focus-banner.focus-final .live-focus-title { - color: #d9e3f2; + color: #ffffff; } .live-screen-head { @@ -1292,7 +1447,7 @@ select.name-input { .live-screen-head h3 { margin: 0; - color: #eaf4ff; + color: var(--live-white); font-size: 17px; } @@ -1306,17 +1461,18 @@ select.name-input { .live-chip { padding: 4px 8px; border-radius: 999px; - border: 1px solid rgba(130, 187, 230, 0.38); - background: rgba(27, 39, 75, 0.72); - color: #cae9ff; + border: 1px solid rgba(0, 140, 168, 0.45); + background: #0d1931; + color: rgba(255, 255, 255, 0.86); font-size: 11px; font-weight: 700; letter-spacing: 0.4px; } .live-chip.strong { - border-color: rgba(255, 214, 124, 0.4); - color: #ffd67c; + border-color: rgba(237, 46, 35, 0.6); + color: #ffffff; + background: rgba(237, 46, 35, 0.16); } .live-swap-enter-active, @@ -1339,9 +1495,8 @@ select.name-input { .live-mode-footer { margin-top: 0; padding: 10px 12px; - border: 1px solid rgba(158, 198, 238, 0.38); - border-radius: 14px; - background: #1a1e3f; + border-top: 1px solid rgba(0, 140, 168, 0.45); + background: #0d1931; display: grid; grid-template-columns: 1fr 1fr 1fr; align-items: center; @@ -1350,10 +1505,10 @@ select.name-input { } .live-mode-fixed-footer { - position: fixed; - inset-inline: 24px; - bottom: calc(var(--live-footer-offset) + env(safe-area-inset-bottom)); - box-shadow: 0 10px 24px rgba(8, 18, 39, 0.02); + position: static; + inset-inline: auto; + bottom: auto; + box-shadow: none; } .live-footer-slot { @@ -1375,21 +1530,265 @@ select.name-input { .live-footer-logo { max-width: min(35vw, 200px); - padding: 10px; + padding: 8px 6px; width: auto; height: auto; object-fit: contain; - filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2)); + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.28)); } .live-footer-slot.right .live-footer-logo { max-width: min(45vw, 280px); } +.live-tie-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 10px; +} + +.live-tie-group-card { + border: 1px solid rgba(0, 140, 168, 0.45); + border-radius: 12px; + background: #0d1931; + padding: 8px; +} + +.live-tie-group-title { + margin: 2px 0 8px; + font-size: 14px; + color: #ffffff; + letter-spacing: 0.2px; +} + .multi-round-cell { min-width: 98px; } +.position-board { + margin-bottom: 14px; +} + +.position-board-head { + margin-bottom: 8px; +} + +.position-board-toggle { + width: 100%; + border: 1px solid #d4deed; + border-radius: 12px; + background: linear-gradient(180deg, #ffffff 0%, #f3f8ff 100%); + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + padding: 10px 12px; + text-align: start; + cursor: pointer; +} + +.position-board-title { + font-size: 14px; + font-weight: 800; + color: #1b1f40; +} + +.position-board-meta { + font-family: "IBM Plex Mono", monospace; + font-size: 12px; + color: #576284; +} + +.position-board-alert { + margin: 6px 4px 0; + display: inline-block; + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + font-weight: 800; + color: #ffffff; + background: #ed2e23; + border-radius: 999px; + padding: 2px 8px; + animation: position-overflow-text-flash 1.1s ease-in-out infinite; +} + +.position-board-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 12px; +} + +.position-column { + border: 1px solid #d4deed; + border-radius: 12px; + background: #f7faff; + overflow: hidden; +} + +.position-column.position-column-overflow { + border-color: #ed2e23; + animation: position-overflow-flash 1.1s ease-in-out infinite; +} + +.position-column.position-column-overflow .position-column-head { + background: rgba(237, 46, 35, 0.14); + border-bottom-color: rgba(237, 46, 35, 0.5); +} + +.position-column-head { + border-bottom: 1px solid #d8e2f2; + background: #edf3ff; +} + +.position-column-toggle { + width: 100%; + border: 0; + background: transparent; + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + padding: 10px 12px; + text-align: start; + cursor: pointer; +} + +@keyframes position-overflow-flash { + 0% { + box-shadow: 0 0 0 0 rgba(237, 46, 35, 0.08); + } + 50% { + box-shadow: 0 0 0 3px rgba(237, 46, 35, 0.32); + } + 100% { + box-shadow: 0 0 0 0 rgba(237, 46, 35, 0.08); + } +} + +.position-column-title { + font-size: 14px; + font-weight: 700; + color: #1b1f40; +} + +.position-column-count { + font-family: "IBM Plex Mono", monospace; + font-size: 12px; + color: #576284; +} + +.position-column-count.danger { + color: #a31610; + font-weight: 800; + animation: position-overflow-text-flash 1.1s ease-in-out infinite; +} + +.position-column-alert { + margin: 0 12px 8px; + display: inline-block; + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + font-weight: 800; + color: #ffffff; + background: #ed2e23; + border-radius: 999px; + padding: 2px 8px; + animation: position-overflow-text-flash 1.1s ease-in-out infinite; +} + +@keyframes position-overflow-text-flash { + 0% { + opacity: 0.7; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.7; + } +} + +.position-column-list { + min-height: 72px; + padding: 10px; + display: grid; + gap: 8px; +} + +.position-card { + display: flex; + gap: 10px; + align-items: center; + padding: 8px; + border: 1px solid #d1dbed; + border-radius: 10px; + background: #ffffff; + cursor: grab; +} + +.position-card:active { + cursor: grabbing; +} + +.position-badge { + width: 26px; + height: 26px; + border-radius: 999px; + background: #1b1f40; + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + font-family: "IBM Plex Mono", monospace; + font-size: 12px; + flex-shrink: 0; +} + +.position-avatar { + width: 38px; + height: 38px; + border-radius: 50%; + object-fit: cover; + border: 1px solid #d4deed; + flex-shrink: 0; +} + +.position-card-text { + min-width: 0; +} + +.position-card-text .name-main { + margin: 0; + font-size: 13px; +} + +.position-card-text .name-sub { + margin: 1px 0 0; + font-size: 11px; +} + +.position-score { + margin: 4px 0 0; + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + color: #556286; +} + +.position-empty { + border: 1px dashed #cfd8ea; + border-radius: 10px; + padding: 12px; + text-align: center; + color: #7483a7; + font-size: 12px; +} + +.position-actions { + margin-top: 8px; + display: flex; + justify-content: flex-end; +} + .score-input-compact { width: 84px; min-height: 36px; @@ -1468,27 +1867,38 @@ select.name-input { aspect-ratio: 16 / 10; } -.live-score-table thead th { - background: #171b3c; +.live-score-table thead th, +.live-screen-card .score-table thead th { + background: #008ca8; + color: #ffffff; +} + +.live-screen-card .score-table { + background: #0d1931; } .live-screen-card .score-table td { - color: #f2f8ff; - border-top-color: rgba(158, 186, 216, 0.22); + color: #ffffff; + border-top-color: rgba(0, 140, 168, 0.24); + background: #0d1931; +} + +.live-screen-card .name-main { + color: #ffffff; } .live-screen-card .name-sub, .live-screen-card .muted { - color: rgba(213, 232, 255, 0.72); + color: rgba(255, 255, 255, 0.72); } .live-screen-card .table-wrap { - border-color: rgba(158, 186, 216, 0.28); - background: #171b3c; + border-color: rgba(0, 140, 168, 0.32); + background: #0d1931; } .live-screen-card .qualified-row { - background: rgba(16, 160, 194, 0.12); + background: rgba(0, 140, 168, 0.16); } .live-podium { @@ -1499,8 +1909,8 @@ select.name-input { } .podium-live-card .podium-col { - background: #1a1e3f; - border: 1px solid rgba(177, 205, 236, 0.26); + background: #0d1931; + border: 1px solid rgba(0, 140, 168, 0.34); box-shadow: 0 18px 34px rgba(2, 10, 25, 0.45); min-width: 0; max-width: 330px; @@ -1508,21 +1918,21 @@ select.name-input { } .podium-live-card .podium-col.pos-1 { - border-top: 10px solid rgba(231, 196, 95, 0.4); - background: #1a1e3f; + border-top: 10px solid #d4af37; + background: #0d1931; height: 430px; - box-shadow: 0 22px 44px rgba(231, 196, 95, 0.15); + box-shadow: 0 22px 44px rgba(212, 175, 55, 0.2); } .podium-live-card .podium-col.pos-2 { - border-top: 10px solid rgba(184, 195, 215, 0.4); - background: #1a1e3f; + border-top: 10px solid #c0c0c0; + background: #0d1931; height: 350px; } .podium-live-card .podium-col.pos-3 { - border-top: 10px solid rgba(207, 139, 69, 0.4); - background: #1a1e3f; + border-top: 10px solid #cd7f32; + background: #0d1931; height: 320px; } @@ -1538,6 +1948,20 @@ select.name-input { width: 150px; height: 150px; border-width: 6px; + border-color: rgba(0, 140, 168, 0.82); + background: #0d1931; +} + +.podium-live-card .podium-col.pos-1 .podium-img { + border-color: #d4af37; +} + +.podium-live-card .podium-col.pos-2 .podium-img { + border-color: #c0c0c0; +} + +.podium-live-card .podium-col.pos-3 .podium-img { + border-color: #cd7f32; } .podium-live-card .podium-col.pos-1 .podium-img { @@ -1575,7 +1999,7 @@ select.name-input { } .podium-live-card .podium-name.empty { - color: rgba(204, 220, 240, 0.78); + color: rgba(255, 255, 255, 0.72); font-size: 22px; -webkit-line-clamp: 2; line-clamp: 2; @@ -1585,20 +2009,32 @@ select.name-input { .podium-live-card .podium-score { background: rgba(255, 255, 255, 0.08); - color: #ffd888; - border-color: rgba(255, 255, 255, 0.18); + color: #ffffff; + border-color: rgba(0, 140, 168, 0.34); margin-bottom: 0; font-size: 16px; padding: 10px 16px; } +.podium-live-card .podium-col.pos-1 .podium-score { + border-color: #d4af37; +} + +.podium-live-card .podium-col.pos-2 .podium-score { + border-color: #c0c0c0; +} + +.podium-live-card .podium-col.pos-3 .podium-score { + border-color: #cd7f32; +} + .podium-live-card .podium-col.pos-1 .podium-score { font-size: 18px; padding: 12px 18px; } .podium-live-card .podium-medal-icon { - filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.35)); + filter: grayscale(1) brightness(1.2) drop-shadow(0 4px 10px rgba(0, 0, 0, 0.35)); font-size: 60px; margin-top: 86px; } @@ -1774,7 +2210,7 @@ select.name-input { } .podium-live-card .podium-score-sub { - color: rgba(220, 234, 252, 0.86); + color: rgba(255, 255, 255, 0.82); } .toast { @@ -1874,6 +2310,97 @@ select.name-input { display: block; } +.score-section-block { + border: 1px solid var(--score-group-border, var(--border)); + border-radius: 12px; + background: var(--score-group-soft, #f7f9ff); + margin-bottom: 12px; + overflow: hidden; +} + +.score-section-block.score-section-overflow { + border-color: #ed2e23; + animation: score-section-overflow-flash 1.1s ease-in-out infinite; +} + +.score-section-toggle { + width: 100%; + border: 0; + background: transparent; + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + padding: 10px 12px; + cursor: pointer; + color: var(--text); + font-weight: 800; + box-shadow: inset 4px 0 0 var(--score-group-accent, #7f8ca8); +} + +.score-section-title { + text-align: start; +} + +.score-section-meta { + font-family: "IBM Plex Mono", monospace; + font-size: 12px; + opacity: 0.8; +} + +.score-section-alert { + margin: 0 12px 8px; + display: inline-block; + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + font-weight: 800; + color: #ffffff; + background: #ed2e23; + border-radius: 999px; + padding: 2px 8px; + animation: score-section-overflow-text-flash 1.1s ease-in-out infinite; +} + +.score-section-block .table-wrap { + border: 0; + border-top: 1px dashed var(--score-group-border, #d2daec); + border-radius: 0; + margin: 0; +} + +.score-section-block.tie-break { + box-shadow: 0 8px 20px rgba(27, 31, 64, 0.08); +} + +.score-section-block.tie-break .score-section-toggle { + background: var(--score-group-soft, rgba(127, 140, 168, 0.14)); + box-shadow: inset 6px 0 0 var(--score-group-accent, #7f8ca8); +} + +@keyframes score-section-overflow-flash { + 0% { + box-shadow: 0 0 0 0 rgba(237, 46, 35, 0.1); + } + 50% { + box-shadow: 0 0 0 3px rgba(237, 46, 35, 0.3); + } + 100% { + box-shadow: 0 0 0 0 rgba(237, 46, 35, 0.1); + } +} + +@keyframes score-section-overflow-text-flash { + 0% { + opacity: 0.7; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.7; + } +} + .mobile-score-cards { display: none; } @@ -2323,7 +2850,9 @@ select.name-input { } .live-settings-grid, - .inline-settings { + .inline-settings, + .stage-control-grid, + .stage-status-grid { grid-template-columns: 1fr; } @@ -2339,11 +2868,6 @@ select.name-input { grid-template-columns: 1fr; } - .live-mode-root { - --live-footer-height: 62px; - --live-footer-offset: 10px; - } - .live-mode-shell { border-radius: 12px; min-height: 70dvh; @@ -2373,7 +2897,7 @@ select.name-input { } .live-mode-fixed-footer { - inset-inline: 10px; + inset-inline: auto; } .live-footer-logo { @@ -2384,6 +2908,10 @@ select.name-input { max-width: min(40vw, 150px); } + .live-tie-grid { + grid-template-columns: 1fr; + } + .status-row { justify-content: flex-start; } diff --git a/frontend/src/utils/scoreGroupTheme.js b/frontend/src/utils/scoreGroupTheme.js index 62edbf6..98a47ac 100644 --- a/frontend/src/utils/scoreGroupTheme.js +++ b/frontend/src/utils/scoreGroupTheme.js @@ -1,15 +1,24 @@ import { normalizedGroupCode } from './groups' const PRESET = { - A: { accent: '#2f4d9a', soft: 'rgba(47, 77, 154, 0.10)', border: 'rgba(47, 77, 154, 0.34)' }, - B: { accent: '#0d8fa5', soft: 'rgba(13, 143, 165, 0.10)', border: 'rgba(13, 143, 165, 0.34)' }, - C: { accent: '#d54a3f', soft: 'rgba(213, 74, 63, 0.10)', border: 'rgba(213, 74, 63, 0.34)' }, - D: { accent: '#c2891f', soft: 'rgba(194, 137, 31, 0.12)', border: 'rgba(194, 137, 31, 0.36)' }, - F1: { accent: '#5b4fc9', soft: 'rgba(91, 79, 201, 0.11)', border: 'rgba(91, 79, 201, 0.36)' }, - F2: { accent: '#0f9f63', soft: 'rgba(15, 159, 99, 0.11)', border: 'rgba(15, 159, 99, 0.36)' }, - U: { accent: '#7f8ca8', soft: 'rgba(127, 140, 168, 0.11)', border: 'rgba(127, 140, 168, 0.34)' }, + A: { accent: '#0d1931', soft: 'rgba(13, 25, 49, 0.14)', border: 'rgba(13, 25, 49, 0.44)' }, + B: { accent: '#008ca8', soft: 'rgba(0, 140, 168, 0.14)', border: 'rgba(0, 140, 168, 0.44)' }, + C: { accent: '#ed2e23', soft: 'rgba(237, 46, 35, 0.14)', border: 'rgba(237, 46, 35, 0.44)' }, + D: { accent: '#1b325f', soft: 'rgba(27, 50, 95, 0.14)', border: 'rgba(27, 50, 95, 0.42)' }, + F1: { accent: '#ed2e23', soft: 'rgba(237, 46, 35, 0.14)', border: 'rgba(237, 46, 35, 0.44)' }, + F2: { accent: '#008ca8', soft: 'rgba(0, 140, 168, 0.14)', border: 'rgba(0, 140, 168, 0.44)' }, + U: { accent: '#4c5f86', soft: 'rgba(76, 95, 134, 0.14)', border: 'rgba(76, 95, 134, 0.4)' }, } +const THEME_PALETTE = [ + { accent: '#0d1931', soft: 'rgba(13, 25, 49, 0.15)', border: 'rgba(13, 25, 49, 0.46)' }, + { accent: '#008ca8', soft: 'rgba(0, 140, 168, 0.15)', border: 'rgba(0, 140, 168, 0.46)' }, + { accent: '#ed2e23', soft: 'rgba(237, 46, 35, 0.15)', border: 'rgba(237, 46, 35, 0.46)' }, + { accent: '#1b325f', soft: 'rgba(27, 50, 95, 0.15)', border: 'rgba(27, 50, 95, 0.44)' }, + { accent: '#0d5f72', soft: 'rgba(13, 95, 114, 0.15)', border: 'rgba(13, 95, 114, 0.44)' }, + { accent: '#b8261d', soft: 'rgba(184, 38, 29, 0.15)', border: 'rgba(184, 38, 29, 0.44)' }, +] + export function scoreGroupToken(row) { const finalGroup = Number(row?.finalGroup || 0) if (finalGroup === 1) return 'F1' @@ -30,12 +39,7 @@ export function scoreGroupStyle(row) { } } - const hue = hashToHue(token) - return { - '--score-group-accent': `hsl(${hue} 70% 38%)`, - '--score-group-soft': `hsla(${hue}, 72%, 46%, 0.10)`, - '--score-group-border': `hsla(${hue}, 70%, 38%, 0.34)`, - } + return groupThemeStyleForKey(token) } export function scoreValueStyle(scoreValue) { @@ -49,6 +53,26 @@ export function scoreValueStyle(scoreValue) { } } +export function groupThemeStyleForKey(key, options = {}) { + const raw = String(key || '').trim().toUpperCase() + const compact = raw.replace(/^[A-Z]/, '') + const numeric = Number.parseInt(compact || raw, 10) + const idx = Number.isFinite(numeric) && numeric > 0 ? (numeric - 1) % THEME_PALETTE.length : hashToHue(raw || 'U') % THEME_PALETTE.length + const base = THEME_PALETTE[idx] + if (!options.tieBreak) { + return { + '--score-group-accent': base.accent, + '--score-group-soft': base.soft, + '--score-group-border': base.border, + } + } + return { + '--score-group-accent': base.accent, + '--score-group-soft': base.soft.replace('0.15', '0.2'), + '--score-group-border': base.border, + } +} + function hashToHue(value) { const input = String(value || 'U') let hash = 0