This commit is contained in:
2026-04-03 09:55:36 +04:00
parent 2465bc2ec3
commit 27143319e3
22 changed files with 2542 additions and 173 deletions

View File

@@ -66,7 +66,30 @@ CREATE TABLE IF NOT EXISTS app_settings (
);
INSERT OR IGNORE INTO app_settings(key, value) VALUES
('view_proof_in_view', '0');
('view_proof_in_view', '0'),
('live_active_view', 'group_live'),
('live_show_group_live', '1'),
('live_group_display_mode', 'rotate'),
('live_group_fixed_code', ''),
('live_show_prelim_tie', '1'),
('live_show_prelim_overall', '1'),
('live_show_final_groups', '1'),
('live_final_display_mode', 'rotate'),
('live_final_fixed_group', '1'),
('live_show_final_tie', '1'),
('live_show_podium', '1'),
('live_rotation_interval_sec', '5'),
('live_rotation_player_count', '12');
INSERT OR IGNORE INTO scores(stage, player_id, score)
SELECT 'prelim_r1', player_id, score FROM scores WHERE stage = 'preliminary';
INSERT OR IGNORE INTO scores(stage, player_id, score)
SELECT 'final_r1', player_id, score FROM scores WHERE stage = 'final';
INSERT OR IGNORE INTO score_attachments(stage, player_id, image_data)
SELECT 'prelim_r1', player_id, image_data FROM score_attachments WHERE stage = 'preliminary';
INSERT OR IGNORE INTO score_attachments(stage, player_id, image_data)
SELECT 'final_r1', player_id, image_data FROM score_attachments WHERE stage = 'final';
`
if _, err := db.Exec(schema); err != nil {
return nil, fmt.Errorf("apply schema: %w", err)

View File

@@ -58,11 +58,14 @@ func (a *App) handleUpdateAdminSettings(c echo.Context) error {
if err := c.Bind(&req); err != nil {
return writeError(c, http.StatusBadRequest, "invalid request body")
}
if req.ViewProofInView == nil {
return writeError(c, http.StatusBadRequest, "viewProofInView is required")
if req.ViewProofInView == nil && req.LiveMode == nil {
return writeError(c, http.StatusBadRequest, "at least one settings field is required")
}
if err := a.updateViewProofInView(*req.ViewProofInView); err != nil {
if err := a.updateSettings(req); err != nil {
if strings.Contains(err.Error(), "no settings to update") {
return writeError(c, http.StatusBadRequest, err.Error())
}
return writeError(c, http.StatusInternalServerError, err.Error())
}
@@ -334,7 +337,7 @@ ON CONFLICT(stage, player_id) DO UPDATE SET score = excluded.score, updated_at =
}
func (a *App) handleResetStageScores(c echo.Context) error {
stage, err := validateStage(c.Param("stage"))
targetStages, err := resolveResetStages(c.Param("stage"))
if err != nil {
return writeError(c, http.StatusBadRequest, err.Error())
}
@@ -344,13 +347,24 @@ func (a *App) handleResetStageScores(c echo.Context) error {
return writeError(c, http.StatusBadRequest, "invalid request body")
}
if _, err := a.db.Exec(`UPDATE scores SET score = 0, updated_at = CURRENT_TIMESTAMP WHERE stage = ?`, stage); err != nil {
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("reset stage scores: %v", err))
tx, err := a.db.Begin()
if err != nil {
return writeError(c, http.StatusInternalServerError, "failed to start reset transaction")
}
if req.ResetProofs {
if _, err := a.db.Exec(`DELETE FROM score_attachments WHERE stage = ?`, stage); err != nil {
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("reset stage proofs: %v", err))
defer tx.Rollback()
for _, stage := range targetStages {
if _, err := tx.Exec(`UPDATE scores SET score = 0, updated_at = CURRENT_TIMESTAMP WHERE stage = ?`, stage); err != nil {
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("reset stage scores: %v", err))
}
if req.ResetProofs {
if _, err := tx.Exec(`DELETE FROM score_attachments WHERE stage = ?`, stage); err != nil {
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("reset stage proofs: %v", err))
}
}
}
if err := tx.Commit(); err != nil {
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("commit stage reset: %v", err))
}
a.events.Broadcast()
@@ -363,6 +377,23 @@ func (a *App) handleResetStageScores(c echo.Context) error {
return c.JSON(http.StatusOK, state)
}
func resolveResetStages(stage string) ([]string, error) {
switch strings.ToLower(strings.TrimSpace(stage)) {
case "preliminary":
return append([]string{}, preliminaryRoundStages...), nil
case "final":
return append([]string{}, finalRoundStages...), nil
case "prelim_tiebreak", "final_tiebreak":
normalized, err := validateStage(stage)
if err != nil {
return nil, err
}
return []string{normalized}, nil
default:
return nil, fmt.Errorf("invalid stage")
}
}
func (a *App) handleUpdateScoreProof(c echo.Context) error {
stage, err := validateStage(c.Param("stage"))
if err != nil {

View File

@@ -6,7 +6,11 @@ import (
"time"
)
var scoreStages = []string{"preliminary", "prelim_tiebreak", "final", "final_tiebreak"}
var preliminaryRoundStages = []string{"prelim_r1", "prelim_r2", "prelim_r3"}
var finalRoundStages = []string{"final_r1", "final_r2"}
var tieBreakStages = []string{"prelim_tiebreak", "final_tiebreak"}
var scoreStages = append(append(append([]string{}, preliminaryRoundStages...), finalRoundStages...), tieBreakStages...)
type App struct {
db *sql.DB
@@ -90,7 +94,24 @@ type StateResponse struct {
}
type AppSettings struct {
ViewProofInView bool `json:"viewProofInView"`
ViewProofInView bool `json:"viewProofInView"`
LiveMode LiveModeSettings `json:"liveMode"`
}
type LiveModeSettings struct {
ActiveView string `json:"activeView"`
ShowGroupLive bool `json:"showGroupLive"`
GroupDisplayMode string `json:"groupDisplayMode"`
GroupFixedCode string `json:"groupFixedCode"`
ShowPrelimTie bool `json:"showPrelimTie"`
ShowPrelimOverall bool `json:"showPrelimOverall"`
ShowFinalGroups bool `json:"showFinalGroups"`
FinalDisplayMode string `json:"finalDisplayMode"`
FinalFixedGroup int `json:"finalFixedGroup"`
ShowFinalTie bool `json:"showFinalTie"`
ShowPodium bool `json:"showPodium"`
RotationIntervalSec int `json:"rotationIntervalSec"`
RotationPlayerCount int `json:"rotationPlayerCount"`
}
type AdminLoginRequest struct {
@@ -121,7 +142,24 @@ type ScoreProofUpdateRequest struct {
}
type AdminSettingsUpdateRequest struct {
ViewProofInView *bool `json:"viewProofInView"`
ViewProofInView *bool `json:"viewProofInView"`
LiveMode *LiveModeSettingsUpdateRequest `json:"liveMode"`
}
type LiveModeSettingsUpdateRequest struct {
ActiveView *string `json:"activeView"`
ShowGroupLive *bool `json:"showGroupLive"`
GroupDisplayMode *string `json:"groupDisplayMode"`
GroupFixedCode *string `json:"groupFixedCode"`
ShowPrelimTie *bool `json:"showPrelimTie"`
ShowPrelimOverall *bool `json:"showPrelimOverall"`
ShowFinalGroups *bool `json:"showFinalGroups"`
FinalDisplayMode *string `json:"finalDisplayMode"`
FinalFixedGroup *int `json:"finalFixedGroup"`
ShowFinalTie *bool `json:"showFinalTie"`
ShowPodium *bool `json:"showPodium"`
RotationIntervalSec *int `json:"rotationIntervalSec"`
RotationPlayerCount *int `json:"rotationPlayerCount"`
}
type ResetStageRequest struct {

View File

@@ -3,33 +3,275 @@ package main
import (
"database/sql"
"fmt"
"strconv"
"strings"
)
func (a *App) readSettings() (AppSettings, error) {
var raw string
err := a.db.QueryRow(`SELECT value FROM app_settings WHERE key = 'view_proof_in_view'`).Scan(&raw)
if err != nil {
if err == sql.ErrNoRows {
return AppSettings{ViewProofInView: false}, nil
}
return AppSettings{}, fmt.Errorf("read app setting view_proof_in_view: %w", err)
const (
settingViewProofInView = "view_proof_in_view"
settingLiveActiveView = "live_active_view"
settingLiveShowGroupLive = "live_show_group_live"
settingLiveGroupDisplayMode = "live_group_display_mode"
settingLiveGroupFixedCode = "live_group_fixed_code"
settingLiveShowPrelimTie = "live_show_prelim_tie"
settingLiveShowPrelimOverall = "live_show_prelim_overall"
settingLiveShowFinalGroups = "live_show_final_groups"
settingLiveFinalDisplayMode = "live_final_display_mode"
settingLiveFinalFixedGroup = "live_final_fixed_group"
settingLiveShowFinalTie = "live_show_final_tie"
settingLiveShowPodium = "live_show_podium"
settingLiveRotationInterval = "live_rotation_interval_sec"
settingLiveRotationPlayers = "live_rotation_player_count"
)
func defaultLiveModeSettings() LiveModeSettings {
return LiveModeSettings{
ActiveView: "group_live",
ShowGroupLive: true,
GroupDisplayMode: "rotate",
GroupFixedCode: "",
ShowPrelimTie: true,
ShowPrelimOverall: true,
ShowFinalGroups: true,
FinalDisplayMode: "rotate",
FinalFixedGroup: 1,
ShowFinalTie: true,
ShowPodium: true,
RotationIntervalSec: 5,
RotationPlayerCount: 12,
}
return AppSettings{ViewProofInView: settingBool(raw)}, nil
}
func (a *App) updateViewProofInView(enabled bool) error {
_, err := a.db.Exec(`
INSERT INTO app_settings(key, value, updated_at)
VALUES('view_proof_in_view', ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
`, settingString(enabled))
func (a *App) readSettings() (AppSettings, error) {
live := defaultLiveModeSettings()
viewProofRaw, err := a.readSettingValue(settingViewProofInView)
if err != nil {
return fmt.Errorf("update app setting view_proof_in_view: %w", err)
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingViewProofInView, err)
}
if raw, err := a.readSettingValue(settingLiveShowGroupLive); err == nil {
live.ShowGroupLive = settingBool(raw)
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveShowGroupLive, err)
}
if raw, err := a.readSettingValue(settingLiveActiveView); err == nil {
live.ActiveView = normalizeLiveActiveView(raw, live.ActiveView)
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveActiveView, err)
}
if raw, err := a.readSettingValue(settingLiveGroupDisplayMode); err == nil {
live.GroupDisplayMode = normalizeLiveDisplayMode(raw, live.GroupDisplayMode)
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveGroupDisplayMode, err)
}
if raw, err := a.readSettingValue(settingLiveGroupFixedCode); err == nil {
live.GroupFixedCode = strings.ToUpper(strings.TrimSpace(raw))
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveGroupFixedCode, err)
}
if raw, err := a.readSettingValue(settingLiveShowPrelimTie); err == nil {
live.ShowPrelimTie = settingBool(raw)
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveShowPrelimTie, err)
}
if raw, err := a.readSettingValue(settingLiveShowPrelimOverall); err == nil {
live.ShowPrelimOverall = settingBool(raw)
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveShowPrelimOverall, err)
}
if raw, err := a.readSettingValue(settingLiveShowFinalGroups); err == nil {
live.ShowFinalGroups = settingBool(raw)
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveShowFinalGroups, err)
}
if raw, err := a.readSettingValue(settingLiveFinalDisplayMode); err == nil {
live.FinalDisplayMode = normalizeLiveDisplayMode(raw, live.FinalDisplayMode)
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveFinalDisplayMode, err)
}
if raw, err := a.readSettingValue(settingLiveFinalFixedGroup); err == nil {
live.FinalFixedGroup = normalizeLiveFinalFixedGroup(raw, live.FinalFixedGroup)
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveFinalFixedGroup, err)
}
if raw, err := a.readSettingValue(settingLiveShowFinalTie); err == nil {
live.ShowFinalTie = settingBool(raw)
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveShowFinalTie, err)
}
if raw, err := a.readSettingValue(settingLiveShowPodium); err == nil {
live.ShowPodium = settingBool(raw)
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveShowPodium, err)
}
if raw, err := a.readSettingValue(settingLiveRotationInterval); err == nil {
live.RotationIntervalSec = normalizeRotationInterval(raw, live.RotationIntervalSec)
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveRotationInterval, err)
}
if raw, err := a.readSettingValue(settingLiveRotationPlayers); err == nil {
live.RotationPlayerCount = normalizeRotationPlayerCount(raw, live.RotationPlayerCount)
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveRotationPlayers, err)
}
return AppSettings{
ViewProofInView: settingBool(viewProofRaw),
LiveMode: live,
}, nil
}
func (a *App) updateSettings(req AdminSettingsUpdateRequest) error {
tx, err := a.db.Begin()
if err != nil {
return fmt.Errorf("begin settings transaction: %w", err)
}
defer tx.Rollback()
hasUpdate := false
if req.ViewProofInView != nil {
hasUpdate = true
if err := upsertSetting(tx, settingViewProofInView, settingString(*req.ViewProofInView)); err != nil {
return err
}
}
if req.LiveMode != nil {
patch := req.LiveMode
if patch.ActiveView != nil {
hasUpdate = true
value := normalizeLiveActiveView(*patch.ActiveView, "group_live")
if err := upsertSetting(tx, settingLiveActiveView, value); err != nil {
return err
}
}
if patch.ShowGroupLive != nil {
hasUpdate = true
if err := upsertSetting(tx, settingLiveShowGroupLive, settingString(*patch.ShowGroupLive)); err != nil {
return err
}
}
if patch.GroupDisplayMode != nil {
hasUpdate = true
value := normalizeLiveDisplayMode(*patch.GroupDisplayMode, "rotate")
if err := upsertSetting(tx, settingLiveGroupDisplayMode, value); err != nil {
return err
}
}
if patch.GroupFixedCode != nil {
hasUpdate = true
value := strings.ToUpper(strings.TrimSpace(*patch.GroupFixedCode))
if err := upsertSetting(tx, settingLiveGroupFixedCode, value); err != nil {
return err
}
}
if patch.ShowPrelimTie != nil {
hasUpdate = true
if err := upsertSetting(tx, settingLiveShowPrelimTie, settingString(*patch.ShowPrelimTie)); err != nil {
return err
}
}
if patch.ShowPrelimOverall != nil {
hasUpdate = true
if err := upsertSetting(tx, settingLiveShowPrelimOverall, settingString(*patch.ShowPrelimOverall)); err != nil {
return err
}
}
if patch.ShowFinalGroups != nil {
hasUpdate = true
if err := upsertSetting(tx, settingLiveShowFinalGroups, settingString(*patch.ShowFinalGroups)); err != nil {
return err
}
}
if patch.FinalDisplayMode != nil {
hasUpdate = true
value := normalizeLiveDisplayMode(*patch.FinalDisplayMode, "rotate")
if err := upsertSetting(tx, settingLiveFinalDisplayMode, value); err != nil {
return err
}
}
if patch.FinalFixedGroup != nil {
hasUpdate = true
value := normalizeLiveFinalFixedGroupInt(*patch.FinalFixedGroup)
if err := upsertSetting(tx, settingLiveFinalFixedGroup, strconv.Itoa(value)); err != nil {
return err
}
}
if patch.ShowFinalTie != nil {
hasUpdate = true
if err := upsertSetting(tx, settingLiveShowFinalTie, settingString(*patch.ShowFinalTie)); err != nil {
return err
}
}
if patch.ShowPodium != nil {
hasUpdate = true
if err := upsertSetting(tx, settingLiveShowPodium, settingString(*patch.ShowPodium)); err != nil {
return err
}
}
if patch.RotationIntervalSec != nil {
hasUpdate = true
value := normalizeRotationIntervalInt(*patch.RotationIntervalSec)
if err := upsertSetting(tx, settingLiveRotationInterval, strconv.Itoa(value)); err != nil {
return err
}
}
if patch.RotationPlayerCount != nil {
hasUpdate = true
value := normalizeRotationPlayerCountInt(*patch.RotationPlayerCount)
if err := upsertSetting(tx, settingLiveRotationPlayers, strconv.Itoa(value)); err != nil {
return err
}
}
}
if !hasUpdate {
return fmt.Errorf("no settings to update")
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit settings transaction: %w", err)
}
return nil
}
func upsertSetting(tx *sql.Tx, key string, value string) error {
_, err := tx.Exec(`
INSERT INTO app_settings(key, value, updated_at)
VALUES(?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
`, key, value)
if err != nil {
return fmt.Errorf("update app setting %s: %w", key, err)
}
return nil
}
func (a *App) readSettingValue(key string) (string, error) {
var raw string
err := a.db.QueryRow(`SELECT value FROM app_settings WHERE key = ?`, key).Scan(&raw)
if err != nil {
if err == sql.ErrNoRows {
return "", nil
}
return "", err
}
return raw, nil
}
func settingBool(value string) bool {
switch strings.TrimSpace(strings.ToLower(value)) {
case "1", "true", "yes", "on":
@@ -45,3 +287,80 @@ func settingString(value bool) string {
}
return "0"
}
func normalizeLiveDisplayMode(value string, fallback string) string {
normalized := strings.TrimSpace(strings.ToLower(value))
if normalized == "fixed" {
return "fixed"
}
if normalized == "rotate" {
return "rotate"
}
if strings.TrimSpace(strings.ToLower(fallback)) == "fixed" {
return "fixed"
}
return "rotate"
}
func normalizeLiveFinalFixedGroup(value string, fallback int) int {
parsed, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return normalizeLiveFinalFixedGroupInt(fallback)
}
return normalizeLiveFinalFixedGroupInt(parsed)
}
func normalizeLiveFinalFixedGroupInt(value int) int {
if value == 2 {
return 2
}
return 1
}
func normalizeRotationInterval(raw string, fallback int) int {
parsed, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil {
return normalizeRotationIntervalInt(fallback)
}
return normalizeRotationIntervalInt(parsed)
}
func normalizeRotationIntervalInt(value int) int {
if value < 3 {
return 3
}
if value > 30 {
return 30
}
return value
}
func normalizeRotationPlayerCount(raw string, fallback int) int {
parsed, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil {
return normalizeRotationPlayerCountInt(fallback)
}
return normalizeRotationPlayerCountInt(parsed)
}
func normalizeRotationPlayerCountInt(value int) int {
if value < 3 {
return 3
}
if value > 40 {
return 40
}
return value
}
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":
return strings.TrimSpace(strings.ToLower(value))
default:
if strings.TrimSpace(strings.ToLower(fallback)) != "" {
return strings.TrimSpace(strings.ToLower(fallback))
}
return "group_live"
}
}

View File

@@ -219,7 +219,7 @@ func computeDerived(players []Player, scores map[string]map[int]int) DerivedStat
NameEn: p.NameEn,
GroupCode: p.GroupCode,
ImageData: p.ImageData,
Score: scores["preliminary"][p.ID],
Score: scoreTotal(scores, p.ID, preliminaryRoundStages),
TieBreak: scores["prelim_tiebreak"][p.ID],
})
}
@@ -349,7 +349,7 @@ func computeDerived(players []Player, scores map[string]map[int]int) DerivedStat
NameEn: p.NameEn,
GroupCode: p.GroupCode,
ImageData: p.ImageData,
Score: scores["final"][finalist.PlayerID],
Score: scoreTotal(scores, finalist.PlayerID, finalRoundStages),
TieBreak: scores["final_tiebreak"][finalist.PlayerID],
Seed: finalist.Seed,
FinalGroup: finalist.FinalGroup,
@@ -460,6 +460,18 @@ func computeDerived(players []Player, scores map[string]map[int]int) DerivedStat
}
}
func scoreTotal(scores map[string]map[int]int, playerID int, stages []string) int {
total := 0
for _, stage := range stages {
stageMap, ok := scores[stage]
if !ok {
continue
}
total += stageMap[playerID]
}
return total
}
func assignDenseRankByScore(rows []RankingRow) {
assignDenseRankBy(rows, func(a, b RankingRow) bool {
return a.Score == b.Score