This commit is contained in:
2026-05-18 13:22:11 +04:00
parent 8cda54548f
commit 5863574a78
24 changed files with 3060 additions and 467 deletions

View File

@@ -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'),

View File

@@ -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 {

View File

@@ -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"`
}

View File

@@ -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"
]
},

View File

@@ -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)

View File

@@ -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)) != "" {

172
backend/stage_sessions.go Normal file
View File

@@ -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
}

View File

@@ -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