Compare commits

..

1 Commits

Author SHA1 Message Date
bd19e0682b update 2026-07-14 22:50:46 +04:00
3195 changed files with 467740 additions and 8485 deletions

View File

@@ -2,6 +2,8 @@
.gitignore .gitignore
node_modules node_modules
**/node_modules **/node_modules
frontend/dist
bin
backend/data backend/data
data data
*.db *.db

View File

@@ -1,182 +0,0 @@
# 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 <token>`)
### 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.

View File

@@ -1,18 +1,26 @@
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
# FROM --platform=$BUILDPLATFORM golang:1.24 AS backend-builder FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend-builder
# WORKDIR /app/backend WORKDIR /app/frontend
# COPY backend/go.mod backend/go.sum ./ COPY frontend/package.json frontend/pnpm-lock.yaml ./
# RUN go mod download RUN corepack enable && pnpm install --frozen-lockfile
# COPY backend/ ./ COPY frontend/ ./
# ARG TARGETARCH RUN pnpm build
# RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH:-amd64} go build -trimpath -ldflags="-s -w" -o /out/shooting-event .
FROM --platform=$BUILDPLATFORM golang:1.24 AS backend-builder
WORKDIR /app/backend
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ ./
COPY --from=frontend-builder /app/frontend/dist ./web
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH:-amd64} go build -trimpath -ldflags="-s -w" -o /out/shooting-event .
FROM --platform=$TARGETPLATFORM alpine:3.21 FROM --platform=$TARGETPLATFORM alpine:3.21
RUN addgroup -S app && adduser -S app -G app && apk add --no-cache ca-certificates RUN addgroup -S app && adduser -S app -G app && apk add --no-cache ca-certificates
WORKDIR /app WORKDIR /app
COPY bin/shooting-event-amd64 /app/shooting-event COPY --from=backend-builder /out/shooting-event /app/shooting-event
COPY frontend/dist /app/web COPY --from=backend-builder /app/backend/web /app/web
RUN mkdir -p /app/data && chown -R app:app /app RUN mkdir -p /app/data && chown -R app:app /app
USER app USER app
ENV PORT=8080 ENV PORT=8080

View File

@@ -1,44 +0,0 @@
# syntax=docker/dockerfile:1.7
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY frontend/ ./
RUN pnpm build
FROM --platform=$BUILDPLATFORM elixir:1.18.4-otp-28 AS phoenix-builder
ENV MIX_ENV=prod
WORKDIR /app/phoenix
RUN mix local.hex --force && mix local.rebar --force
COPY phoenix/mix.exs phoenix/mix.lock ./
RUN mix deps.get --only prod
RUN mix deps.compile
COPY phoenix/config ./config
COPY phoenix/lib ./lib
COPY phoenix/priv ./priv
RUN mix compile
RUN mix release
FROM --platform=$TARGETPLATFORM elixir:1.18.4-otp-28
RUN groupadd -r app && useradd -r -g app app
WORKDIR /app
COPY --from=phoenix-builder /app/phoenix/_build/prod/rel/shooting_event_phx /app
COPY --from=frontend-builder /app/frontend/dist /app/web
RUN mkdir -p /app/data && chown -R app:app /app
USER app
ENV PHX_SERVER=true
ENV PORT=8080
ENV DB_PATH=/app/data/shooting.db
ENV WEB_DIR=/app/web
ENV SECRET_KEY_BASE=pxh_v1_V2Y3TXF4Rk9OUm5FdlVQc2s3M2hqd0t1Q2pwYUx4N3Vjb2I5Wk1qXzJ5RWRmQnJ
EXPOSE 8080
ENTRYPOINT ["/app/bin/shooting_event_phx"]
CMD ["start"]

View File

@@ -3,18 +3,16 @@ SHELL := /bin/bash
PNPM ?= pnpm PNPM ?= pnpm
GO ?= go GO ?= go
CONTAINER_REG ?= repo.ssp-itinfra.com/admin CONTAINER_REG ?= repo.ssp-itinfra.com/admin
IMAGE_NAME ?= shooting-event-2 IMAGE_NAME ?= shooting-event
PHOENIX_IMAGE_NAME ?= $(IMAGE_NAME)-phoenix
ARCH ?= amd64 ARCH ?= amd64
BUILD_DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") BUILD_DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
BUILD_VERSION ?= $(shell git describe --tags --always --dirty) BUILD_VERSION ?= $(shell git describe --tags --always --dirty)
.PHONY: install dev dev-backend dev-frontend build build-frontend build-backend phoenix-dev phoenix-build docker-build docker-run docker-build-phoenix docker-run-phoenix clean .PHONY: install dev dev-backend dev-frontend build build-frontend build-backend docker-build docker-run clean
install: install:
cd frontend && $(PNPM) install cd frontend && $(PNPM) install
cd backend && $(GO) mod tidy cd backend && $(GO) mod tidy
cd phoenix && mix deps.get
dev-backend: dev-backend:
cd backend && $(GO) run . cd backend && $(GO) run .
@@ -36,21 +34,12 @@ build-backend:
mkdir -p bin mkdir -p bin
cd backend && $(GO) build -o ../bin/shooting-event . cd backend && $(GO) build -o ../bin/shooting-event .
build-backend-amd64:
mkdir -p bin
cd backend && GOARCH=amd64 GOOS=linux $(GO) build -o ../bin/shooting-event-amd64 .
build: build-frontend build: build-frontend
rm -rf backend/web rm -rf backend/web
mkdir -p backend/web mkdir -p backend/web
cp -R frontend/dist/. backend/web/ cp -R frontend/dist/. backend/web/
$(MAKE) build-backend $(MAKE) build-backend
phoenix-dev:
cd phoenix && mix deps.get && mix ecto.create && mix ecto.migrate && PORT=$${PORT:-8080} mix phx.server
phoenix-build:
cd phoenix && MIX_ENV=prod mix deps.get --only prod && MIX_ENV=prod mix compile
docker-build: docker-build:
docker buildx build --load --platform=linux/$(ARCH) -t $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH) . docker buildx build --load --platform=linux/$(ARCH) -t $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH) .
docker tag $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH) $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)-latest docker tag $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH) $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)-latest
@@ -63,20 +52,7 @@ docker-run:
mkdir -p data mkdir -p data
docker run --rm -p 8080:8080 -v $(PWD)/data:/app/data $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH) docker run --rm -p 8080:8080 -v $(PWD)/data:/app/data $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)
docker-build-phoenix:
docker buildx build --load --platform=linux/$(ARCH) -f Dockerfile.phoenix -t $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH) .
docker tag $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH) $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)-latest
docker tag $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH) $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)-$(BUILD_VERSION)
docker push $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)
docker push $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)-latest
docker push $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)-$(BUILD_VERSION)
docker-run-phoenix:
mkdir -p data
docker run --rm -p 8080:8080 -v $(PWD)/data:/app/data $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)
release: release:
${MAKE} build
$(MAKE) docker-build $(MAKE) docker-build
clean: clean:

View File

@@ -34,39 +34,29 @@ Production-ready full-stack web app based on your original live score concept.
8. Podium is determined automatically. 8. Podium is determined automatically.
9. If top-3 tie exists, podium tie-break stage appears. 9. If top-3 tie exists, podium tie-break stage appears.
## API Documentation (OpenAPI) ## API Highlights
Interactive docs are now built in: Public:
- Swagger UI: `GET /api/docs` - `GET /api/health`
- OpenAPI JSON: `GET /api/openapi.json` - `GET /api/state`
### Testing with Auth in Swagger UI Admin:
1. Open `/api/docs`. - `POST /api/admin/login`
2. Use the **Login & Authorize** form at the top: - `POST /api/admin/logout`
- Username: `datwyler` (default) - `POST /api/admin/players`
- Password: `datwyler` (default) - `PUT /api/admin/players/:id`
3. The page calls `POST /api/admin/login`, retrieves the token, and auto-authorizes Swagger. - `DELETE /api/admin/players/:id`
4. Use **Try it out** on any admin endpoint. - `PUT /api/admin/scores/:stage/:id`
- `POST /api/admin/scores/:stage/:id/advice`
- `POST /api/admin/scores/:stage/reset`
### Stage Values Stages:
For score update/proof/advice endpoints (`/api/admin/scores/{stage}/...`), use:
- `prelim_r1`
- `prelim_r2`
- `prelim_r3`
- `final_r1`
- `final_r2`
- `prelim_tiebreak`
- `final_tiebreak`
For reset endpoint (`POST /api/admin/scores/{stage}/reset`), use:
- `preliminary` - `preliminary`
- `final`
- `prelim_tiebreak` - `prelim_tiebreak`
- `final`
- `final_tiebreak` - `final_tiebreak`
## Local Development ## Local Development

View File

@@ -69,7 +69,7 @@ func (a *App) loadCurrentScore(stage string, playerID int) (int, error) {
} }
func (a *App) buildAdviceResponse(stage string, playerID int, raw scoreAdviceModelResponse) ScoreAdviceResponse { func (a *App) buildAdviceResponse(stage string, playerID int, raw scoreAdviceModelResponse) ScoreAdviceResponse {
advised := clampInt(raw.AdvisedScore, 0, 100) advised := clampInt(raw.AdvisedScore, 0, 9999)
reason := strings.TrimSpace(raw.Reason) reason := strings.TrimSpace(raw.Reason)
if reason == "" { if reason == "" {
reason = "AI estimated the score from visible impacts." reason = "AI estimated the score from visible impacts."

View File

@@ -1,145 +0,0 @@
package main
import (
_ "embed"
"fmt"
"net/http"
"github.com/labstack/echo/v4"
)
//go:embed openapi/openapi.json
var openAPISpecJSON string
func (a *App) handleOpenAPISpec(c echo.Context) error {
return c.Blob(http.StatusOK, "application/json; charset=utf-8", []byte(openAPISpecJSON))
}
func (a *App) handleOpenAPIDocs(c echo.Context) error {
html := fmt.Sprintf(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Shooting Event API Docs</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style>
body {
margin: 0;
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f7f9fc;
}
.docs-top {
background: #ffffff;
border-bottom: 1px solid #dde5f3;
padding: 14px 16px;
}
.docs-title {
margin: 0 0 8px;
font-size: 18px;
color: #1b1f40;
}
.docs-help {
margin: 0;
font-size: 14px;
color: #4c5773;
}
.auth-panel {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.auth-panel input {
height: 36px;
border: 1px solid #cdd7eb;
border-radius: 8px;
padding: 0 10px;
font-size: 14px;
}
.auth-panel button {
height: 36px;
border: 1px solid #20284f;
background: #20284f;
color: white;
border-radius: 8px;
padding: 0 12px;
cursor: pointer;
}
.auth-status {
font-size: 13px;
color: #38446b;
}
#swagger-ui {
max-width: 1300px;
margin: 0 auto;
}
</style>
</head>
<body>
<section class="docs-top">
<h1 class="docs-title">Shooting Event OpenAPI Docs</h1>
<p class="docs-help">
Use <strong>Try it out</strong> for live testing. For admin endpoints: login with the form below,
then your token is auto-attached to Swagger Authorize.
</p>
<form id="auth-form" class="auth-panel">
<input id="admin-user" type="text" value="datwyler" autocomplete="username" placeholder="Admin username" />
<input id="admin-pass" type="password" value="datwyler" autocomplete="current-password" placeholder="Admin password" />
<button type="submit">Login & Authorize</button>
<span id="auth-status" class="auth-status"></span>
</form>
</section>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
const ui = SwaggerUIBundle({
url: %q,
dom_id: '#swagger-ui',
deepLinking: true,
displayRequestDuration: true,
persistAuthorization: true,
docExpansion: 'list',
tryItOutEnabled: true,
});
async function loginAndAuthorize(event) {
event.preventDefault();
const status = document.getElementById('auth-status');
status.textContent = 'Logging in...';
const username = document.getElementById('admin-user').value || '';
const password = document.getElementById('admin-pass').value || '';
try {
const response = await fetch('/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.message || 'Login failed');
}
if (!payload.token) {
throw new Error('Login succeeded but token was not returned');
}
ui.preauthorizeApiKey('bearerAuth', payload.token);
status.textContent = 'Authorized until ' + (payload.expiresAt || 'token expiry');
} catch (error) {
status.textContent = 'Auth failed: ' + error.message;
}
}
document.getElementById('auth-form').addEventListener('submit', loginAndAuthorize);
</script>
</body>
</html>`, "/api/openapi.json")
return c.HTML(http.StatusOK, html)
}

View File

@@ -59,31 +59,12 @@ CREATE TABLE IF NOT EXISTS score_attachments (
FOREIGN KEY(player_id) REFERENCES players(id) ON DELETE CASCADE 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 ( CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '', value TEXT NOT NULL DEFAULT '',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 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 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_active_view', 'group_live'),

View File

@@ -198,6 +198,6 @@ func scoreAdvicePrompt(stage string, currentScore int) string {
return fmt.Sprintf(`Target scoring assistant. return fmt.Sprintf(`Target scoring assistant.
Stage: %s. Current score: %d. Stage: %s. Current score: %d.
Return STRICT JSON only: Return STRICT JSON only:
{"advisedScore":<int 0..100>,"reason":"<max 18 words>"} {"advisedScore":<int 0..9999>,"reason":"<max 18 words>"}
Do not add markdown or extra fields.`, stage, currentScore) Do not add markdown or extra fields.`, stage, currentScore)
} }

View File

@@ -78,84 +78,6 @@ func (a *App) handleUpdateAdminSettings(c echo.Context) error {
return c.JSON(http.StatusOK, state) 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 { func (a *App) handleCreatePlayer(c echo.Context) error {
var req PlayerCreateRequest var req PlayerCreateRequest
if err := c.Bind(&req); err != nil { if err := c.Bind(&req); err != nil {
@@ -373,77 +295,6 @@ func (a *App) handleAutoGroupPlayers(c echo.Context) error {
return c.JSON(http.StatusOK, state) 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 { func (a *App) handleUpdateScore(c echo.Context) error {
stage, err := validateStage(c.Param("stage")) stage, err := validateStage(c.Param("stage"))
if err != nil { if err != nil {
@@ -459,8 +310,8 @@ func (a *App) handleUpdateScore(c echo.Context) error {
if err := c.Bind(&req); err != nil { if err := c.Bind(&req); err != nil {
return writeError(c, http.StatusBadRequest, "invalid request body") return writeError(c, http.StatusBadRequest, "invalid request body")
} }
if req.Score < 0 || req.Score > 100 { if req.Score < 0 || req.Score > 9999 {
return writeError(c, http.StatusBadRequest, "score must be between 0 and 100") return writeError(c, http.StatusBadRequest, "score must be between 0 and 9999")
} }
res, err := a.db.Exec(` res, err := a.db.Exec(`
@@ -531,9 +382,7 @@ func resolveResetStages(stage string) ([]string, error) {
case "preliminary": case "preliminary":
return append([]string{}, preliminaryRoundStages...), nil return append([]string{}, preliminaryRoundStages...), nil
case "final": case "final":
stages := append([]string{}, finalRoundStages...) return append([]string{}, finalRoundStages...), nil
stages = append(stages, "final_tiebreak")
return stages, nil
case "prelim_tiebreak", "final_tiebreak": case "prelim_tiebreak", "final_tiebreak":
normalized, err := validateStage(stage) normalized, err := validateStage(stage)
if err != nil { if err != nil {

View File

@@ -88,28 +88,11 @@ type StateResponse struct {
Players []Player `json:"players"` Players []Player `json:"players"`
Scores map[string]map[string]int `json:"scores"` Scores map[string]map[string]int `json:"scores"`
ScoreProofs map[string]map[string]string `json:"scoreProofs,omitempty"` ScoreProofs map[string]map[string]string `json:"scoreProofs,omitempty"`
PositionBoards map[string]map[string]PositionSlot `json:"positionBoards"`
Settings AppSettings `json:"settings"` Settings AppSettings `json:"settings"`
Derived DerivedState `json:"derived"` Derived DerivedState `json:"derived"`
CurrentStage *StageSessionState `json:"current_stage"`
LastStage *StageSessionState `json:"last_stage"`
ServerTime string `json:"serverTime"` 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 { type AppSettings struct {
ViewProofInView bool `json:"viewProofInView"` ViewProofInView bool `json:"viewProofInView"`
LiveMode LiveModeSettings `json:"liveMode"` LiveMode LiveModeSettings `json:"liveMode"`
@@ -187,16 +170,6 @@ type AutoGroupPlayersRequest struct {
Groups []string `json:"groups"` 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 { type ScoreAdviceResponse struct {
Stage string `json:"stage"` Stage string `json:"stage"`
PlayerID int `json:"playerId"` PlayerID int `json:"playerId"`
@@ -204,9 +177,3 @@ type ScoreAdviceResponse struct {
Reason string `json:"reason"` Reason string `json:"reason"`
GeneratedAt string `json:"generatedAt"` GeneratedAt string `json:"generatedAt"`
} }
type StageControlStartRequest struct {
Phase string `json:"phase"`
GroupKey string `json:"group"`
Round int `json:"round"`
}

File diff suppressed because it is too large Load Diff

View File

@@ -11,10 +11,6 @@ import (
func registerAPIRoutes(e *echo.Echo, app *App) { func registerAPIRoutes(e *echo.Echo, app *App) {
api := e.Group("/api") api := e.Group("/api")
api.GET("/docs", app.handleOpenAPIDocs)
api.GET("/docs/", app.handleOpenAPIDocs)
api.GET("/openapi.json", app.handleOpenAPISpec)
api.GET("/health", app.handleHealth) api.GET("/health", app.handleHealth)
api.GET("/state", app.handleGetState) api.GET("/state", app.handleGetState)
api.GET("/events", app.handleEvents) api.GET("/events", app.handleEvents)
@@ -22,13 +18,9 @@ func registerAPIRoutes(e *echo.Echo, app *App) {
api.POST("/admin/login", app.handleAdminLogin) api.POST("/admin/login", app.handleAdminLogin)
api.POST("/admin/logout", app.handleAdminLogout, app.adminOnly) api.POST("/admin/logout", app.handleAdminLogout, app.adminOnly)
api.GET("/admin/state", app.handleGetState, 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.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", app.handleCreatePlayer, app.adminOnly)
api.POST("/admin/players/auto-group", app.handleAutoGroupPlayers, 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.PUT("/admin/players/:id", app.handleUpdatePlayer, app.adminOnly)
api.DELETE("/admin/players/:id", app.handleDeletePlayer, app.adminOnly) api.DELETE("/admin/players/:id", app.handleDeletePlayer, app.adminOnly)
api.PUT("/admin/scores/:stage/:id", app.handleUpdateScore, 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 { func normalizeLiveActiveView(value string, fallback string) string {
switch strings.TrimSpace(strings.ToLower(value)) { switch strings.TrimSpace(strings.ToLower(value)) {
case "group_live", "prelim_tie", "prelim_overall", "final_groups", "final_tie", "final_overall", "podium": case "group_live", "prelim_tie", "prelim_overall", "final_groups", "final_tie", "podium":
return strings.TrimSpace(strings.ToLower(value)) return strings.TrimSpace(strings.ToLower(value))
default: default:
if strings.TrimSpace(strings.ToLower(fallback)) != "" { if strings.TrimSpace(strings.ToLower(fallback)) != "" {

View File

@@ -1,172 +0,0 @@
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,21 +8,6 @@ import (
"time" "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) { func (a *App) readState(includeAllProofs bool) (StateResponse, error) {
players, err := a.readPlayers() players, err := a.readPlayers()
if err != nil { if err != nil {
@@ -53,18 +38,6 @@ func (a *App) readState(includeAllProofs bool) (StateResponse, error) {
} }
derived := computeDerived(players, scoreMap) 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{ response := StateResponse{
Competition: CompetitionMeta{ Competition: CompetitionMeta{
@@ -73,11 +46,8 @@ func (a *App) readState(includeAllProofs bool) (StateResponse, error) {
}, },
Players: players, Players: players,
Scores: scoreMapToJSON(scoreMap), Scores: scoreMapToJSON(scoreMap),
PositionBoards: positionBoardMapToJSON(positionBoards),
Settings: settings, Settings: settings,
Derived: derived, Derived: derived,
CurrentStage: currentStage,
LastStage: lastStage,
ServerTime: time.Now().UTC().Format(time.RFC3339), ServerTime: time.Now().UTC().Format(time.RFC3339),
} }
if includeScoreProofs { if includeScoreProofs {
@@ -163,210 +133,6 @@ func scoreProofMapToJSON(proofMap map[string]map[int]string) map[string]map[stri
return out 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 { func (a *App) ensureScoreRows(players []Player) error {
if len(players) == 0 { if len(players) == 0 {
return nil return nil

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="app-shell" :class="['lang-' + language, 'mode-' + mode]"> <div class="app-shell" :class="'lang-' + language">
<input ref="imageInput" type="file" class="hidden-file-input" accept="image/*" capture="environment" @change="onImageSelected" /> <input ref="imageInput" type="file" class="hidden-file-input" accept="image/*" capture="environment" @change="onImageSelected" />
<input ref="scoreProofInput" type="file" class="hidden-file-input" accept="image/*" capture="environment" @change="onScoreProofSelected" /> <input ref="scoreProofInput" type="file" class="hidden-file-input" accept="image/*" capture="environment" @change="onScoreProofSelected" />
@@ -8,11 +8,9 @@
:competition-title="competitionTitle" :competition-title="competitionTitle"
:mode="mode" :mode="mode"
:language="language" :language="language"
:is-fullscreen="isFullscreen"
:server-time="state.serverTime ? formatServerTime(state.serverTime) : ''" :server-time="state.serverTime ? formatServerTime(state.serverTime) : ''"
@change-mode="mode = $event" @change-mode="mode = $event"
@change-language="setLanguage" @change-language="setLanguage"
@toggle-fullscreen="toggleFullscreen"
/> />
<main class="page-content"> <main class="page-content">
@@ -71,20 +69,18 @@
v-else-if="mode === 'live'" v-else-if="mode === 'live'"
:t="t" :t="t"
:live-settings="liveSettings" :live-settings="liveSettings"
:position-boards="positionBoards"
:live-group-code="liveMonitorGroupCode" :live-group-code="liveMonitorGroupCode"
:live-group-members="liveMonitorMembers" :live-group-members="liveMonitorMembers"
:prelim-tie="prelimTie"
:prelim-tie-rows="prelimTieRows" :prelim-tie-rows="prelimTieRows"
:preliminary-rows="preliminaryRows" :preliminary-rows="preliminaryRows"
:finalists="finalists" :finalists="finalists"
:live-final-group="liveMonitorFinalGroup" :live-final-group="liveMonitorFinalGroup"
:live-final-rows="liveMonitorFinalRows" :live-final-rows="liveMonitorFinalRows"
:final-overall-rows="finalRows"
:final-tie-rows="finalTieRows" :final-tie-rows="finalTieRows"
:podium-ordered="podiumOrdered" :podium-ordered="podiumOrdered"
:player-image="playerImage" :player-image="playerImage"
:display-name="displayName" :display-name="displayName"
:secondary-name="secondaryName"
:score-for="scoreFor" :score-for="scoreFor"
:prelim-total="preliminaryTotalScore" :prelim-total="preliminaryTotalScore"
:final-total="finalTotalScore" :final-total="finalTotalScore"
@@ -114,12 +110,6 @@
:admin-tab="adminTab" :admin-tab="adminTab"
:view-proof-in-view="canViewProofs" :view-proof-in-view="canViewProofs"
:live-settings="liveSettings" :live-settings="liveSettings"
:stage-control="stageControl"
:stage-options="stageOptions"
:current-stage="currentStage"
:last-stage="lastStage"
:active-scoring-stage="currentScoringStageKey"
:active-scoring-group="currentScoringGroupKey"
:group-setup-input="groupSetupInput" :group-setup-input="groupSetupInput"
:admin-group-cards="adminGroupCards" :admin-group-cards="adminGroupCards"
:new-player="newPlayer" :new-player="newPlayer"
@@ -139,12 +129,10 @@
:final-total-score="finalTotalScore" :final-total-score="finalTotalScore"
:prelim-tie="prelimTie" :prelim-tie="prelimTie"
:final-tie="finalTie" :final-tie="finalTie"
:position-boards="positionBoards"
:score-filters="scoreFilters" :score-filters="scoreFilters"
:display-name="displayName" :display-name="displayName"
:secondary-name="secondaryName" :secondary-name="secondaryName"
:score-input-value="scoreInputValue" :score-input-value="scoreInputValue"
:score-input-invalid="scoreInputInvalid"
:on-score-focus="onScoreFocus" :on-score-focus="onScoreFocus"
:on-score-input="onScoreInput" :on-score-input="onScoreInput"
:on-score-commit="onScoreCommit" :on-score-commit="onScoreCommit"
@@ -158,10 +146,6 @@
@logout="adminLogout" @logout="adminLogout"
@toggle-view-proof="updateViewProofSetting" @toggle-view-proof="updateViewProofSetting"
@update-live-setting="updateLiveModeSetting" @update-live-setting="updateLiveModeSetting"
@update-stage-control="updateStageControl"
@start-stage="startCurrentStage"
@end-stage="endCurrentStage"
@navigate-current-stage="navigateToCurrentStage"
@change-admin-tab="adminTab = $event" @change-admin-tab="adminTab = $event"
@update:group-setup-input="groupSetupInput = $event" @update:group-setup-input="groupSetupInput = $event"
@save-group-setup="saveGroupSetup" @save-group-setup="saveGroupSetup"
@@ -178,8 +162,6 @@
@remove-player-image="removePlayerImage" @remove-player-image="removePlayerImage"
@delete-player="deletePlayer" @delete-player="deletePlayer"
@request-reset-stage="openResetStageModal" @request-reset-stage="openResetStageModal"
@reset-final-group-by-seed="resetFinalGroupBySeed"
@save-position-board="savePositionBoard"
@update-score-filter="updateScoreFilter" @update-score-filter="updateScoreFilter"
/> />
</template> </template>
@@ -234,14 +216,12 @@ const FALLBACK_SYNC_MS = 15000
const STREAM_RETRY_MS = 1500 const STREAM_RETRY_MS = 1500
const PRELIM_ROUND_STAGES = ['prelim_r1', 'prelim_r2', 'prelim_r3'] const PRELIM_ROUND_STAGES = ['prelim_r1', 'prelim_r2', 'prelim_r3']
const FINAL_ROUND_STAGES = ['final_r1', 'final_r2'] const FINAL_ROUND_STAGES = ['final_r1', 'final_r2']
const POSITION_BOARDS = ['preliminary', 'final', 'prelim_tiebreak', 'final_tiebreak']
const loading = ref(true) const loading = ref(true)
const mode = ref('live') const mode = ref('live')
const viewTab = ref('groups') const viewTab = ref('groups')
const adminTab = ref('players') const adminTab = ref('players')
const language = ref(localStorage.getItem('shooting_lang') === 'en' ? 'en' : 'ar') const language = ref(localStorage.getItem('shooting_lang') === 'en' ? 'en' : 'ar')
const isFullscreen = ref(false)
const state = ref(createInitialState()) const state = ref(createInitialState())
const primaryGroups = ref(loadPrimaryGroups(localStorage)) const primaryGroups = ref(loadPrimaryGroups(localStorage))
@@ -263,11 +243,6 @@ const scoreFilters = reactive({
prelim_tiebreak: '', prelim_tiebreak: '',
final_tiebreak: '', final_tiebreak: '',
}) })
const stageControl = reactive({
phase: 'preliminary',
group: '',
round: 1,
})
const liveGroupIndex = ref(0) const liveGroupIndex = ref(0)
const liveMode = ref('rotate') const liveMode = ref('rotate')
@@ -319,14 +294,6 @@ const finalRows = computed(() => state.value.derived?.finalRanking?.rows || [])
const podiumRows = computed(() => state.value.derived?.podium || []) const podiumRows = computed(() => state.value.derived?.podium || [])
const prelimTie = computed(() => state.value.derived?.preliminaryRanking?.tieBreak || { required: false, resolved: true, slots: 0, playerIds: [] }) 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 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 canViewProofs = computed(() => Boolean(state.value.settings?.viewProofInView))
const liveSettings = computed(() => { const liveSettings = computed(() => {
const raw = state.value.settings?.liveMode || {} const raw = state.value.settings?.liveMode || {}
@@ -349,10 +316,6 @@ const liveSettings = computed(() => {
rotationPlayerCount: Number(raw.rotationPlayerCount) > 0 ? Number(raw.rotationPlayerCount) : 12, 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(() => [ const viewTabs = computed(() => [
{ id: 'groups', label: t('tabs.groups') }, { id: 'groups', label: t('tabs.groups') },
@@ -442,8 +405,7 @@ const liveGroupCode = computed(() => {
const liveMembers = computed(() => { const liveMembers = computed(() => {
const code = normalizedGroupCode(liveGroupCode.value) const code = normalizedGroupCode(liveGroupCode.value)
if (!code) return [] if (!code) return []
const rows = playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code) return playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
return sortRowsByPreliminaryPosition(rows)
}) })
const liveMonitorGroupRotationCodes = computed(() => activeGroupCodes.value) const liveMonitorGroupRotationCodes = computed(() => activeGroupCodes.value)
@@ -458,8 +420,7 @@ const liveMonitorGroupCode = computed(() => {
const liveMonitorMembers = computed(() => { const liveMonitorMembers = computed(() => {
const code = normalizedGroupCode(liveMonitorGroupCode.value) const code = normalizedGroupCode(liveMonitorGroupCode.value)
if (!code) return [] if (!code) return []
const rows = playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code) return playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
return sortRowsByPreliminaryPosition(rows)
}) })
const liveMonitorFinalGroup = computed(() => { const liveMonitorFinalGroup = computed(() => {
if (liveSettings.value.finalDisplayMode === 'fixed') { if (liveSettings.value.finalDisplayMode === 'fixed') {
@@ -467,32 +428,17 @@ const liveMonitorFinalGroup = computed(() => {
} }
return liveMonitorFinalIndex.value % 2 === 0 ? 1 : 2 return liveMonitorFinalIndex.value % 2 === 0 ? 1 : 2
}) })
const finalBoardGroupedRows = computed(() => { const liveMonitorFinalRows = computed(() => (liveMonitorFinalGroup.value === 2 ? finalGroup2.value : finalGroup1.value))
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 podiumOrdered = computed(() => [podiumRows.value[1] || null, podiumRows.value[0] || null, podiumRows.value[2] || null])
const prelimTieRows = computed(() => { const prelimTieRows = computed(() => {
const ids = new Set(prelimTie.value.playerIds || []) const ids = new Set(prelimTie.value.playerIds || [])
const rows = preliminaryRows.value.filter((row) => ids.has(row.playerId)) return preliminaryRows.value.filter((row) => ids.has(row.playerId))
return sortRowsByBoardPosition('prelim_tiebreak', rows)
}) })
const finalTieRows = computed(() => { const finalTieRows = computed(() => {
const ids = new Set(finalTie.value.playerIds || []) const ids = new Set(finalTie.value.playerIds || [])
const rows = finalRows.value.filter((row) => ids.has(row.playerId)) return finalRows.value.filter((row) => ids.has(row.playerId))
return sortRowsByBoardPosition('final_tiebreak', rows)
}) })
const playerByID = computed(() => { const playerByID = computed(() => {
@@ -533,7 +479,8 @@ const adminPreliminaryRows = computed(() => {
rank: derived?.rank ?? 0, rank: derived?.rank ?? 0,
} }
}) })
return sortRowsByBoardPosition('preliminary', rows) rows.sort(compareRowsByGroupThenId)
return rows
}) })
const adminPrelimTieRows = computed(() => { const adminPrelimTieRows = computed(() => {
@@ -553,7 +500,8 @@ const adminPrelimTieRows = computed(() => {
} }
}) })
.filter(Boolean) .filter(Boolean)
return sortRowsByBoardPosition('prelim_tiebreak', rows) rows.sort(compareRowsByScoreGroupThenId)
return rows
}) })
const adminFinalRows = computed(() => { const adminFinalRows = computed(() => {
@@ -573,7 +521,17 @@ const adminFinalRows = computed(() => {
} }
}) })
return sortRowsByBoardPosition('final', rows) 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
}) })
const adminFinalTieRows = computed(() => { const adminFinalTieRows = computed(() => {
@@ -596,29 +554,8 @@ const adminFinalTieRows = computed(() => {
}) })
.filter(Boolean) .filter(Boolean)
return sortRowsByBoardPosition('final_tiebreak', rows) rows.sort(compareRowsByScoreGroupThenId)
}) return 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) => { watch(language, (value) => {
@@ -702,20 +639,7 @@ watch(
{ immediate: true }, { immediate: true },
) )
watch(
() => `${stageControl.phase}|${JSON.stringify(stageOptions.value)}`,
() => {
applyStageControlDefaults()
},
{ immediate: true },
)
onMounted(async () => { onMounted(async () => {
onFullscreenChange()
document.addEventListener('fullscreenchange', onFullscreenChange)
document.addEventListener('webkitfullscreenchange', onFullscreenChange)
document.addEventListener('mozfullscreenchange', onFullscreenChange)
document.addEventListener('MSFullscreenChange', onFullscreenChange)
document.documentElement.lang = language.value document.documentElement.lang = language.value
document.documentElement.dir = language.value === 'ar' ? 'rtl' : 'ltr' document.documentElement.dir = language.value === 'ar' ? 'rtl' : 'ltr'
await fetchState() await fetchState()
@@ -723,10 +647,6 @@ onMounted(async () => {
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('fullscreenchange', onFullscreenChange)
document.removeEventListener('webkitfullscreenchange', onFullscreenChange)
document.removeEventListener('mozfullscreenchange', onFullscreenChange)
document.removeEventListener('MSFullscreenChange', onFullscreenChange)
stopLiveRotation() stopLiveRotation()
stopLiveMonitorRotation() stopLiveMonitorRotation()
stopRealtimeSync() stopRealtimeSync()
@@ -742,60 +662,9 @@ function setLanguage(next) {
if (next === 'ar' || next === 'en') language.value = next if (next === 'ar' || next === 'en') language.value = next
} }
function getFullscreenElement() {
return (
document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement ||
document.msFullscreenElement ||
null
)
}
function onFullscreenChange() {
isFullscreen.value = Boolean(getFullscreenElement())
}
function requestFullscreenFor(element) {
if (!element) return Promise.resolve()
const fn =
element.requestFullscreen ||
element.webkitRequestFullscreen ||
element.mozRequestFullScreen ||
element.msRequestFullscreen
if (!fn) return Promise.resolve()
const result = fn.call(element)
if (result && typeof result.then === 'function') return result
return Promise.resolve()
}
function exitFullscreen() {
const fn =
document.exitFullscreen ||
document.webkitExitFullscreen ||
document.mozCancelFullScreen ||
document.msExitFullscreen
if (!fn) return Promise.resolve()
const result = fn.call(document)
if (result && typeof result.then === 'function') return result
return Promise.resolve()
}
async function toggleFullscreen() {
try {
if (getFullscreenElement()) {
await exitFullscreen()
return
}
await requestFullscreenFor(document.documentElement)
} catch {
// Ignore fullscreen errors, mostly caused by browser policy.
}
}
function normalizeLiveActiveView(value) { function normalizeLiveActiveView(value) {
const normalized = String(value || '').trim().toLowerCase() const normalized = String(value || '').trim().toLowerCase()
const allowed = new Set(['group_live', 'prelim_tie', 'prelim_overall', 'final_groups', 'final_tie', 'final_overall', 'podium']) const allowed = new Set(['group_live', 'prelim_tie', 'prelim_overall', 'final_groups', 'final_tie', 'podium'])
return allowed.has(normalized) ? normalized : 'group_live' return allowed.has(normalized) ? normalized : 'group_live'
} }
@@ -855,116 +724,6 @@ function compareRowsByScoreGroupThenId(a, b) {
return compareRowsByGroupThenId(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() { function saveGroupSetup() {
const parsed = parseGroupList(groupSetupInput.value) const parsed = parseGroupList(groupSetupInput.value)
primaryGroups.value = parsed.length > 0 ? parsed : DEFAULT_PRIMARY_GROUPS primaryGroups.value = parsed.length > 0 ? parsed : DEFAULT_PRIMARY_GROUPS
@@ -1006,55 +765,6 @@ function updateScoreFilter({ stage, value }) {
scoreFilters[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 resetFinalGroupBySeed() {
const ranked = [...adminFinalRows.value]
.filter((row) => Number(row.seed || 0) > 0)
.sort((a, b) => {
const seedA = Number(a.seed || 0)
const seedB = Number(b.seed || 0)
if (seedA !== seedB) return seedA - seedB
return Number(a.playerId || 0) - Number(b.playerId || 0)
})
if (ranked.length === 0) {
showToast(t('labels.noFinalists'), 'error')
return
}
const slots = ranked.map((row, index) => ({
playerId: row.playerId,
groupKey: index < 6 ? '1' : '2',
position: (index % 6) + 1,
}))
try {
state.value = await api('/admin/positions/final', {
method: 'PUT',
admin: true,
body: { slots },
})
showToast(t('messages.saved'))
} catch (error) {
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
}
}
async function autoGroupEvenly() { async function autoGroupEvenly() {
if (primaryGroups.value.length === 0) { if (primaryGroups.value.length === 0) {
showToast(t('messages.noGroupsConfigured'), 'error') showToast(t('messages.noGroupsConfigured'), 'error')
@@ -1216,139 +926,6 @@ async function updateLiveModeSetting(patch) {
await updateAdminSettings({ liveMode: 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() { async function createPlayer() {
if (!newPlayer.nameAr || !newPlayer.nameEn) { if (!newPlayer.nameAr || !newPlayer.nameEn) {
showToast(t('messages.mustProvideNames'), 'error') showToast(t('messages.mustProvideNames'), 'error')
@@ -1632,7 +1209,7 @@ async function applySuggestedScore() {
if (!scoreAdviceModal.advice) return if (!scoreAdviceModal.advice) return
const stage = scoreAdviceModal.stage const stage = scoreAdviceModal.stage
const playerId = scoreAdviceModal.playerId const playerId = scoreAdviceModal.playerId
const score = clampScoreValue(scoreAdviceModal.advice.advisedScore) const score = Number(scoreAdviceModal.advice.advisedScore || 0)
await updateScoreValue(stage, playerId, score) await updateScoreValue(stage, playerId, score)
scoreAdviceModal.currentScore = score scoreAdviceModal.currentScore = score
closeScoreAdviceModal() closeScoreAdviceModal()
@@ -1710,48 +1287,26 @@ function scoreDraftKey(stage, playerId) {
return `${stage}:${playerId}` return `${stage}:${playerId}`
} }
function clampScoreValue(value) {
let score = Number(value)
if (!Number.isFinite(score)) score = 0
score = Math.trunc(score)
if (score < 0) return 0
if (score > 100) return 100
return score
}
function scoreInputValue(stage, playerId) { function scoreInputValue(stage, playerId) {
const draft = scoreDrafts[scoreDraftKey(stage, playerId)] const draft = scoreDrafts[scoreDraftKey(stage, playerId)]
if (draft) return draft.value if (draft) return draft.value
return String(scoreFor(stage, playerId)) return String(scoreFor(stage, playerId))
} }
function scoreInputInvalid(stage, playerId) {
const draft = scoreDrafts[scoreDraftKey(stage, playerId)]
return Boolean(draft?.invalid)
}
function parseDraftScoreValue(value) {
let score = Number(value === '' ? 0 : value)
if (!Number.isFinite(score)) score = 0
return Math.trunc(score)
}
function onScoreFocus(stage, playerId) { function onScoreFocus(stage, playerId) {
const key = scoreDraftKey(stage, playerId) const key = scoreDraftKey(stage, playerId)
if (!scoreDrafts[key]) { if (!scoreDrafts[key]) {
scoreDrafts[key] = { value: String(scoreFor(stage, playerId)), committing: false, invalid: false } scoreDrafts[key] = { value: String(scoreFor(stage, playerId)), committing: false }
} }
} }
function onScoreInput(stage, playerId, event) { function onScoreInput(stage, playerId, event) {
const key = scoreDraftKey(stage, playerId) const key = scoreDraftKey(stage, playerId)
const raw = String(event?.target?.value ?? '') const raw = String(event?.target?.value ?? '')
const digits = raw.replace(/[^\d]/g, '').slice(0, 3) const cleaned = raw.replace(/[^\d]/g, '').slice(0, 4)
const parsed = parseDraftScoreValue(digits) const current = scoreDrafts[key] || { value: String(scoreFor(stage, playerId)), committing: false }
const invalid = digits !== '' && (parsed < 0 || parsed > 100) scoreDrafts[key] = { ...current, value: cleaned }
const current = scoreDrafts[key] || { value: String(scoreFor(stage, playerId)), committing: false, invalid: false } if (event?.target && event.target.value !== cleaned) event.target.value = cleaned
scoreDrafts[key] = { ...current, value: digits, invalid }
if (event?.target && event.target.value !== digits) event.target.value = digits
} }
async function onScoreCommit(stage, playerId) { async function onScoreCommit(stage, playerId) {
@@ -1759,11 +1314,11 @@ async function onScoreCommit(stage, playerId) {
const draft = scoreDrafts[key] const draft = scoreDrafts[key]
if (!draft || draft.committing) return if (!draft || draft.committing) return
const score = parseDraftScoreValue(draft.value) let score = Number(draft.value === '' ? 0 : draft.value)
const invalid = score < 0 || score > 100 if (!Number.isFinite(score)) score = 0
score = Math.trunc(score)
if (invalid) { if (score < 0 || score > 9999) {
scoreDrafts[key] = { ...draft, invalid: true }
showToast(t('messages.invalidScore'), 'error') showToast(t('messages.invalidScore'), 'error')
return return
} }
@@ -1773,12 +1328,12 @@ async function onScoreCommit(stage, playerId) {
return return
} }
scoreDrafts[key] = { ...draft, invalid: false, committing: true } scoreDrafts[key] = { ...draft, committing: true }
await updateScoreValue(stage, playerId, score, key) await updateScoreValue(stage, playerId, score, key)
} }
async function updateScoreValue(stage, playerId, score, draftKey = '') { async function updateScoreValue(stage, playerId, score, draftKey = '') {
if (score < 0 || score > 100) { if (score < 0 || score > 9999) {
showToast(t('messages.invalidScore'), 'error') showToast(t('messages.invalidScore'), 'error')
return return
} }

View File

@@ -59,20 +59,7 @@
<h2>{{ t('liveMode') }}</h2> <h2>{{ t('liveMode') }}</h2>
<p>{{ t('labels.liveModeVisibility') }}</p> <p>{{ t('labels.liveModeVisibility') }}</p>
</div> </div>
<LiveSettingsCard <LiveSettingsCard :t="t" :live-settings="liveSettings" :assignable-groups="assignableGroups" @update-live-setting="$emit('update-live-setting', $event)" />
:t="t"
:live-settings="liveSettings"
:assignable-groups="assignableGroups"
:stage-control="stageControl"
:stage-options="stageOptions"
:current-stage="currentStage"
:last-stage="lastStage"
@update-live-setting="$emit('update-live-setting', $event)"
@update-stage-control="$emit('update-stage-control', $event)"
@start-stage="$emit('start-stage')"
@end-stage="$emit('end-stage')"
@navigate-current-stage="$emit('navigate-current-stage')"
/>
</section> </section>
<section v-show="adminTab === 'preliminary'" class="panel"> <section v-show="adminTab === 'preliminary'" class="panel">
@@ -94,18 +81,6 @@
/> />
</div> </div>
<PositionBoardEditor
:t="t"
board="preliminary"
:rows="preliminaryScoreRows"
:slots="positionBoards.preliminary"
:player-image="playerImage"
:display-name="displayName"
:secondary-name="secondaryName"
:available-groups="assignableGroups"
@save="$emit('save-position-board', $event)"
/>
<MultiRoundScoreEditor <MultiRoundScoreEditor
:t="t" :t="t"
:rows="preliminaryScoreRows" :rows="preliminaryScoreRows"
@@ -117,17 +92,12 @@
:total-score="preliminaryTotalScore" :total-score="preliminaryTotalScore"
:show-filter="false" :show-filter="false"
:filter-text="scoreFilters.preliminary" :filter-text="scoreFilters.preliminary"
:section-by-group="true"
:overflow-limit="6"
:highlight-stage="activeScoringStage"
:highlight-group="activeScoringGroup"
:show-group="true" :show-group="true"
:show-rank="false" :show-rank="false"
:player-image="playerImage" :player-image="playerImage"
:display-name="displayName" :display-name="displayName"
:secondary-name="secondaryName" :secondary-name="secondaryName"
:score-input-value="scoreInputValue" :score-input-value="scoreInputValue"
:score-input-invalid="scoreInputInvalid"
:on-score-focus="onScoreFocus" :on-score-focus="onScoreFocus"
:on-score-input="onScoreInput" :on-score-input="onScoreInput"
:on-score-commit="onScoreCommit" :on-score-commit="onScoreCommit"
@@ -135,7 +105,6 @@
:score-proof-for="scoreProofFor" :score-proof-for="scoreProofFor"
:open-score-proof-uploader="openScoreProofUploader" :open-score-proof-uploader="openScoreProofUploader"
:open-proof-preview="openProofPreview" :open-proof-preview="openProofPreview"
:show-proof-controls="false"
/> />
</section> </section>
@@ -158,33 +127,18 @@
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'prelim_tiebreak')">{{ t('actions.resetScores') }}</button> <button class="btn btn-outline" @click="$emit('request-reset-stage', 'prelim_tiebreak')">{{ t('actions.resetScores') }}</button>
</div> </div>
<PositionBoardEditor
:t="t"
board="prelim_tiebreak"
:rows="prelimTieScoreRows"
:slots="positionBoards.prelim_tiebreak"
:player-image="playerImage"
:display-name="displayName"
:secondary-name="secondaryName"
:numeric-groups="true"
@save="$emit('save-position-board', $event)"
/>
<ScoreStageEditor <ScoreStageEditor
:t="t" :t="t"
stage="prelim_tiebreak" stage="prelim_tiebreak"
color-mode="score" color-mode="score"
:rows="prelimTieScoreRows" :rows="prelimTieScoreRows"
:filter-text="scoreFilters.prelim_tiebreak" :filter-text="scoreFilters.prelim_tiebreak"
:section-by-group="true"
:tie-break-section-mode="true"
:show-score-before-input="true" :show-score-before-input="true"
:input-label="t('table.tieScore')" :input-label="t('table.tieScore')"
:player-image="playerImage" :player-image="playerImage"
:display-name="displayName" :display-name="displayName"
:secondary-name="secondaryName" :secondary-name="secondaryName"
:score-input-value="scoreInputValue" :score-input-value="scoreInputValue"
:score-input-invalid="scoreInputInvalid"
:on-score-focus="onScoreFocus" :on-score-focus="onScoreFocus"
:on-score-input="onScoreInput" :on-score-input="onScoreInput"
:on-score-commit="onScoreCommit" :on-score-commit="onScoreCommit"
@@ -194,9 +148,6 @@
:remove-score-proof="removeScoreProof" :remove-score-proof="removeScoreProof"
:open-proof-preview="openProofPreview" :open-proof-preview="openProofPreview"
:request-score-advice="requestScoreAdvice" :request-score-advice="requestScoreAdvice"
:highlight-stage="activeScoringStage"
:highlight-group="activeScoringGroup"
:show-proof-controls="false"
@update:filter="$emit('update-score-filter', { stage: 'prelim_tiebreak', value: $event })" @update:filter="$emit('update-score-filter', { stage: 'prelim_tiebreak', value: $event })"
/> />
</template> </template>
@@ -210,7 +161,6 @@
<div class="panel-actions"> <div class="panel-actions">
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'final')">{{ t('actions.resetScores') }}</button> <button class="btn btn-outline" @click="$emit('request-reset-stage', 'final')">{{ t('actions.resetScores') }}</button>
<button class="btn btn-outline" @click="$emit('reset-final-group-by-seed')">{{ t('actions.resetFinalGroupBySeed') }}</button>
</div> </div>
<div class="stage-filter-bar"> <div class="stage-filter-bar">
@@ -222,18 +172,6 @@
/> />
</div> </div>
<PositionBoardEditor
:t="t"
board="final"
:rows="finalScoreRows"
:slots="positionBoards.final"
:player-image="playerImage"
:display-name="displayName"
:secondary-name="secondaryName"
:numeric-groups="true"
@save="$emit('save-position-board', $event)"
/>
<MultiRoundScoreEditor <MultiRoundScoreEditor
:t="t" :t="t"
:rows="finalScoreRows" :rows="finalScoreRows"
@@ -244,17 +182,12 @@
:total-score="finalTotalScore" :total-score="finalTotalScore"
:show-filter="false" :show-filter="false"
:filter-text="scoreFilters.final_common" :filter-text="scoreFilters.final_common"
:section-by-group="true"
:overflow-limit="6"
:highlight-stage="activeScoringStage"
:highlight-group="activeScoringGroup"
:show-group="true" :show-group="true"
:show-seed="true" :show-seed="true"
:player-image="playerImage" :player-image="playerImage"
:display-name="displayName" :display-name="displayName"
:secondary-name="secondaryName" :secondary-name="secondaryName"
:score-input-value="scoreInputValue" :score-input-value="scoreInputValue"
:score-input-invalid="scoreInputInvalid"
:on-score-focus="onScoreFocus" :on-score-focus="onScoreFocus"
:on-score-input="onScoreInput" :on-score-input="onScoreInput"
:on-score-commit="onScoreCommit" :on-score-commit="onScoreCommit"
@@ -262,7 +195,6 @@
:score-proof-for="scoreProofFor" :score-proof-for="scoreProofFor"
:open-score-proof-uploader="openScoreProofUploader" :open-score-proof-uploader="openScoreProofUploader"
:open-proof-preview="openProofPreview" :open-proof-preview="openProofPreview"
:show-proof-controls="false"
/> />
</section> </section>
@@ -275,38 +207,23 @@
<div class="hint-box danger" v-if="finalTie.required && !finalTie.resolved">{{ t('messages.finalTieUnresolved') }}</div> <div class="hint-box danger" v-if="finalTie.required && !finalTie.resolved">{{ t('messages.finalTieUnresolved') }}</div>
<div class="empty-state good" v-if="!finalTie.required">{{ t('messages.noFinalTie') }}</div> <div class="empty-state good" v-if="!finalTie.required">{{ t('messages.noFinalTie') }}</div>
<template v-if="finalTie.required">
<div class="panel-actions"> <div class="panel-actions">
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'final_tiebreak')">{{ t('actions.resetScores') }}</button> <button class="btn btn-outline" @click="$emit('request-reset-stage', 'final_tiebreak')">{{ t('actions.resetScores') }}</button>
</div> </div>
<template v-if="finalTie.required">
<PositionBoardEditor
:t="t"
board="final_tiebreak"
:rows="finalTieScoreRows"
:slots="positionBoards.final_tiebreak"
:player-image="playerImage"
:display-name="displayName"
:secondary-name="secondaryName"
:numeric-groups="true"
@save="$emit('save-position-board', $event)"
/>
<ScoreStageEditor <ScoreStageEditor
:t="t" :t="t"
stage="final_tiebreak" stage="final_tiebreak"
color-mode="score" color-mode="score"
:rows="finalTieScoreRows" :rows="finalTieScoreRows"
:filter-text="scoreFilters.final_tiebreak" :filter-text="scoreFilters.final_tiebreak"
:section-by-group="true"
:tie-break-section-mode="true"
:show-score-before-input="true" :show-score-before-input="true"
:input-label="t('table.tieScore')" :input-label="t('table.tieScore')"
:player-image="playerImage" :player-image="playerImage"
:display-name="displayName" :display-name="displayName"
:secondary-name="secondaryName" :secondary-name="secondaryName"
:score-input-value="scoreInputValue" :score-input-value="scoreInputValue"
:score-input-invalid="scoreInputInvalid"
:on-score-focus="onScoreFocus" :on-score-focus="onScoreFocus"
:on-score-input="onScoreInput" :on-score-input="onScoreInput"
:on-score-commit="onScoreCommit" :on-score-commit="onScoreCommit"
@@ -316,9 +233,6 @@
:remove-score-proof="removeScoreProof" :remove-score-proof="removeScoreProof"
:open-proof-preview="openProofPreview" :open-proof-preview="openProofPreview"
:request-score-advice="requestScoreAdvice" :request-score-advice="requestScoreAdvice"
:highlight-stage="activeScoringStage"
:highlight-group="activeScoringGroup"
:show-proof-controls="false"
@update:filter="$emit('update-score-filter', { stage: 'final_tiebreak', value: $event })" @update:filter="$emit('update-score-filter', { stage: 'final_tiebreak', value: $event })"
/> />
</template> </template>
@@ -369,7 +283,6 @@ import PlayersManagementTab from './admin/PlayersManagementTab.vue'
import ScoreStageEditor from './admin/ScoreStageEditor.vue' import ScoreStageEditor from './admin/ScoreStageEditor.vue'
import LiveSettingsCard from './admin/LiveSettingsCard.vue' import LiveSettingsCard from './admin/LiveSettingsCard.vue'
import MultiRoundScoreEditor from './admin/MultiRoundScoreEditor.vue' import MultiRoundScoreEditor from './admin/MultiRoundScoreEditor.vue'
import PositionBoardEditor from './admin/PositionBoardEditor.vue'
defineProps({ defineProps({
t: { type: Function, required: true }, t: { type: Function, required: true },
@@ -377,12 +290,6 @@ defineProps({
adminTab: { type: String, required: true }, adminTab: { type: String, required: true },
viewProofInView: { type: Boolean, default: false }, viewProofInView: { type: Boolean, default: false },
liveSettings: { type: Object, required: true }, 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: '' }, groupSetupInput: { type: String, default: '' },
adminGroupCards: { type: Array, required: true }, adminGroupCards: { type: Array, required: true },
newPlayer: { type: Object, required: true }, newPlayer: { type: Object, required: true },
@@ -402,12 +309,10 @@ defineProps({
finalTotalScore: { type: Function, required: true }, finalTotalScore: { type: Function, required: true },
prelimTie: { type: Object, required: true }, prelimTie: { type: Object, required: true },
finalTie: { type: Object, required: true }, finalTie: { type: Object, required: true },
positionBoards: { type: Object, required: true },
scoreFilters: { type: Object, required: true }, scoreFilters: { type: Object, required: true },
displayName: { type: Function, required: true }, displayName: { type: Function, required: true },
secondaryName: { type: Function, required: true }, secondaryName: { type: Function, required: true },
scoreInputValue: { type: Function, required: true }, scoreInputValue: { type: Function, required: true },
scoreInputInvalid: { type: Function, required: true },
onScoreFocus: { type: Function, required: true }, onScoreFocus: { type: Function, required: true },
onScoreInput: { type: Function, required: true }, onScoreInput: { type: Function, required: true },
onScoreCommit: { type: Function, required: true }, onScoreCommit: { type: Function, required: true },
@@ -424,10 +329,6 @@ defineEmits([
'logout', 'logout',
'toggle-view-proof', 'toggle-view-proof',
'update-live-setting', 'update-live-setting',
'update-stage-control',
'start-stage',
'end-stage',
'navigate-current-stage',
'change-admin-tab', 'change-admin-tab',
'update:group-setup-input', 'update:group-setup-input',
'save-group-setup', 'save-group-setup',
@@ -444,8 +345,6 @@ defineEmits([
'remove-player-image', 'remove-player-image',
'delete-player', 'delete-player',
'request-reset-stage', 'request-reset-stage',
'reset-final-group-by-seed',
'save-position-board',
'update-score-filter', 'update-score-filter',
]) ])
</script> </script>

View File

@@ -3,8 +3,8 @@
<div class="masthead-main"> <div class="masthead-main">
<div class="masthead-brand"> <div class="masthead-brand">
<picture> <picture>
<source v-if="mode !== 'live'" srcset="/logo.svg" type="image/svg+xml" /> <source srcset="/logo.svg" type="image/svg+xml" />
<img class="masthead-logo" :src="mode === 'live' ? '/logo_white.png' : '/logo.png'" :alt="competitionTitle" /> <img class="masthead-logo" src="/logo.png" :alt="competitionTitle" />
</picture> </picture>
<p class="masthead-subtitle">{{ t('subtitle') }}</p> <p class="masthead-subtitle">{{ t('subtitle') }}</p>
</div> </div>
@@ -16,35 +16,6 @@
<span>{{ optionsLabel }}</span> <span>{{ optionsLabel }}</span>
<span class="control-toggle-meta">{{ modeShort }} · {{ languageShort }}</span> <span class="control-toggle-meta">{{ modeShort }} · {{ languageShort }}</span>
</button> </button>
<button
class="control-icon-btn"
type="button"
:class="{ active: isFullscreen }"
:aria-label="fullscreenActionLabel"
:title="fullscreenActionLabel"
@click="toggleFullscreen"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
v-if="!isFullscreen"
d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
v-else
d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div> </div>
<Transition name="controls-reveal"> <Transition name="controls-reveal">
@@ -52,7 +23,7 @@
<div class="control-box"> <div class="control-box">
<p class="control-label">{{ t('labels.mode') }}</p> <p class="control-label">{{ t('labels.mode') }}</p>
<div class="language-switcher mode-switcher"> <div class="language-switcher mode-switcher">
<!-- <button class="lang-btn" :class="{ active: mode === 'view' }" @click="changeMode('view')">{{ t('viewMode') }}</button> --> <button class="lang-btn" :class="{ active: mode === 'view' }" @click="changeMode('view')">{{ t('viewMode') }}</button>
<button class="lang-btn" :class="{ active: mode === 'live' }" @click="changeMode('live')">{{ t('liveMode') }}</button> <button class="lang-btn" :class="{ active: mode === 'live' }" @click="changeMode('live')">{{ t('liveMode') }}</button>
<button class="lang-btn" :class="{ active: mode === 'admin' }" @click="changeMode('admin')">{{ t('adminMode') }}</button> <button class="lang-btn" :class="{ active: mode === 'admin' }" @click="changeMode('admin')">{{ t('adminMode') }}</button>
</div> </div>
@@ -86,10 +57,9 @@ const props = defineProps({
mode: { type: String, required: true }, mode: { type: String, required: true },
language: { type: String, required: true }, language: { type: String, required: true },
serverTime: { type: String, default: '' }, serverTime: { type: String, default: '' },
isFullscreen: { type: Boolean, default: false },
}) })
const emit = defineEmits(['change-mode', 'change-language', 'toggle-fullscreen']) const emit = defineEmits(['change-mode', 'change-language'])
const controlsOpen = ref(false) const controlsOpen = ref(false)
const controlsRoot = ref(null) const controlsRoot = ref(null)
@@ -101,7 +71,6 @@ const modeShort = computed(() => {
return props.language === 'ar' ? 'إدارة' : 'Admin' return props.language === 'ar' ? 'إدارة' : 'Admin'
}) })
const languageShort = computed(() => (props.language === 'ar' ? 'AR' : 'EN')) const languageShort = computed(() => (props.language === 'ar' ? 'AR' : 'EN'))
const fullscreenActionLabel = computed(() => (props.isFullscreen ? props.t('actions.exitFullscreen') : props.t('actions.enterFullscreen')))
const modeStatusLabel = computed(() => { const modeStatusLabel = computed(() => {
if (props.mode === 'view') return '● ' + props.t('labels.liveTracker') if (props.mode === 'view') return '● ' + props.t('labels.liveTracker')
if (props.mode === 'live') return '● ' + props.t('liveMode') if (props.mode === 'live') return '● ' + props.t('liveMode')
@@ -122,10 +91,6 @@ function changeLanguage(nextLanguage) {
controlsOpen.value = false controlsOpen.value = false
} }
function toggleFullscreen() {
emit('toggle-fullscreen')
}
function onGlobalPointerDown(event) { function onGlobalPointerDown(event) {
if (!controlsOpen.value) return if (!controlsOpen.value) return
const root = controlsRoot.value const root = controlsRoot.value

View File

@@ -1,10 +1,14 @@
<template> <template>
<div class="live-mode-root">
<section class="live-mode-shell"> <section class="live-mode-shell">
<div class="live-mode-overlay"> <div class="live-mode-overlay">
<!-- <div class="live-screen-head live-head-selection"> <header class="live-mode-header">
<h2>{{ t('sections.liveModeTitle') }}</h2>
<p>{{ t('sections.liveModeSubtitle') }}</p>
</header>
<div class="live-screen-head live-head-selection">
<span class="live-chip strong">{{ activeViewLabel }}</span> <span class="live-chip strong">{{ activeViewLabel }}</span>
</div> --> </div>
<div class="live-mode-grid single"> <div class="live-mode-grid single">
<article v-if="activeView === 'group_live'" class="live-screen-card wide"> <article v-if="activeView === 'group_live'" class="live-screen-card wide">
@@ -26,7 +30,7 @@
<table class="score-table live-score-table"> <table class="score-table live-score-table">
<thead> <thead>
<tr> <tr>
<th>{{ t('table.position') }}</th> <th>{{ t('table.rank') }}</th>
<th>{{ t('table.competitor') }}</th> <th>{{ t('table.competitor') }}</th>
<th>{{ t('table.round1') }}</th> <th>{{ t('table.round1') }}</th>
<th>{{ t('table.round2') }}</th> <th>{{ t('table.round2') }}</th>
@@ -35,13 +39,14 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(entry, index) in rankedLiveGroupMembers" :key="'live-group-' + entry.player.id"> <tr v-for="entry in rankedLiveGroupMembers" :key="'live-group-' + entry.player.id">
<td class="mono rank">{{ index + 1 }}</td> <td class="mono rank">{{ entry.rank }}</td>
<td> <td>
<div class="competitor-cell compact"> <div class="competitor-cell compact">
<img :src="playerImage(entry.player)" :alt="entry.player.nameAr" class="competitor-image" /> <img :src="playerImage(entry.player)" :alt="entry.player.nameAr" class="competitor-image" />
<div> <div>
<p class="name-main">{{ displayName(entry.player) }}</p> <p class="name-main">{{ displayName(entry.player) }}</p>
<p class="name-sub">{{ secondaryName(entry.player) }}</p>
</div> </div>
</div> </div>
</td> </td>
@@ -63,41 +68,41 @@
<h3>{{ t('sections.prelimTieTitle') }}</h3> <h3>{{ t('sections.prelimTieTitle') }}</h3>
<div class="live-chip-row"> <div class="live-chip-row">
<span class="live-chip">{{ t('table.tieScore') }}</span> <span class="live-chip">{{ t('table.tieScore') }}</span>
<span v-if="tieGroupCountForActiveView > 1" class="live-chip">{{ currentTieGroupPageDisplay }}</span> <span v-if="listPageCount > 1" class="live-chip">{{ currentListPageDisplay }}</span>
</div> </div>
</div> </div>
<Transition name="live-swap" mode="out-in"> <Transition name="live-swap" mode="out-in">
<article v-if="activePrelimTieGroup" :key="'live-pre-tie-group-' + activePrelimTieGroup.groupKey" class="live-tie-group-card"> <div :key="'prelim-tie-page-' + listPageIndex" class="live-swap-block">
<h4 class="live-tie-group-title">{{ t('labels.group') }} {{ activePrelimTieGroup.groupKey }}</h4>
<div class="table-wrap"> <div class="table-wrap">
<table class="score-table"> <table class="score-table">
<thead> <thead>
<tr> <tr>
<th>{{ t('table.position') }}</th> <th>#</th>
<th>{{ t('table.competitor') }}</th> <th>{{ t('table.competitor') }}</th>
<th>{{ t('table.tieScore') }}</th> <th>{{ t('table.tieScore') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="entry in activePrelimTieGroup.rows" :key="'live-pre-tie-' + activePrelimTieGroup.groupKey + '-' + entry.row.playerId"> <tr v-for="(row, index) in pagedPrelimTieRows" :key="'live-pre-tie-' + row.playerId">
<td class="mono">{{ entry.position }}</td> <td class="mono">{{ listPageStart + index + 1 }}</td>
<td> <td>
<div class="competitor-cell compact"> <div class="competitor-cell compact">
<img :src="playerImage(entry.row)" :alt="entry.row.nameAr" class="competitor-image" /> <img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
<div> <div>
<p class="name-main">{{ displayName(entry.row) }}</p> <p class="name-main">{{ displayName(row) }}</p>
<p class="name-sub">{{ secondaryName(row) }}</p>
</div> </div>
</div> </div>
</td> </td>
<td class="mono strong">{{ entry.row.tieBreak }}</td> <td class="mono strong">{{ row.tieBreak }}</td>
</tr> </tr>
<tr v-if="prelimTieRows.length === 0"><td colspan="3" class="muted center">{{ t('messages.noPrelimTie') }}</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</article> </div>
</Transition> </Transition>
<div v-if="groupedPrelimTieRows.length === 0" class="muted center">{{ t('messages.noPrelimTie') }}</div>
</article> </article>
<article v-else-if="activeView === 'prelim_overall'" class="live-screen-card wide"> <article v-else-if="activeView === 'prelim_overall'" class="live-screen-card wide">
@@ -125,25 +130,22 @@
<tr <tr
v-for="row in pagedPreliminaryRows" v-for="row in pagedPreliminaryRows"
:key="'live-pre-overall-' + row.playerId" :key="'live-pre-overall-' + row.playerId"
:class="{ 'qualified-row': isFinalist(row.playerId) }"
> >
<td class="mono rank"> <td class="mono rank">{{ row.rank }}</td>
<div class="live-rank-cell">
<span>{{ row.rank }}</span>
<span v-if="prelimOverallHintLabel(row)" class="live-rank-hint">{{ prelimOverallHintLabel(row) }}</span>
</div>
</td>
<td> <td>
<div class="competitor-cell compact"> <div class="competitor-cell compact">
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" /> <img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
<div> <div>
<p class="name-main">{{ displayName(row) }}</p> <p class="name-main">{{ displayName(row) }}</p>
<p class="name-sub">{{ secondaryName(row) }}</p>
</div> </div>
</div> </div>
</td> </td>
<td class="mono strong">{{ row.score }}</td> <td class="mono strong">{{ row.score }}</td>
<td class="mono">{{ row.tieBreak }}</td> <td class="mono">{{ row.tieBreak }}</td>
</tr> </tr>
<tr v-if="preliminaryRowsForLive.length === 0"><td colspan="4" class="muted center">{{ t('labels.noPlayers') }}</td></tr> <tr v-if="preliminaryRows.length === 0"><td colspan="4" class="muted center">{{ t('labels.noPlayers') }}</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -162,15 +164,15 @@
<Transition name="live-swap" mode="out-in"> <Transition name="live-swap" mode="out-in">
<div :key="'live-final-swap-' + liveFinalGroup" class="live-swap-block"> <div :key="'live-final-swap-' + liveFinalGroup" class="live-swap-block">
<div class="live-focus-banner focus-final"> <div class="live-focus-banner focus-final">
<span class="live-focus-label">{{ currentFinalSeedLabel }}</span> <span class="live-focus-label">{{ t('tabs.final') }}</span>
<strong class="live-focus-title">{{ currentFinalGroupNumber }}</strong> <strong class="live-focus-title">{{ currentFinalGroupLabel }}</strong>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table class="score-table live-score-table"> <table class="score-table live-score-table">
<thead> <thead>
<tr> <tr>
<th>{{ t('table.position') }}</th> <th>{{ t('table.rank') }}</th>
<th>{{ t('table.competitor') }}</th> <th>{{ t('table.competitor') }}</th>
<th>{{ t('table.round1') }}</th> <th>{{ t('table.round1') }}</th>
<th>{{ t('table.round2') }}</th> <th>{{ t('table.round2') }}</th>
@@ -178,13 +180,14 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(entry, index) in rankedLiveFinalRows" :key="'live-final-group-' + entry.row.playerId"> <tr v-for="entry in rankedLiveFinalRows" :key="'live-final-group-' + entry.row.playerId">
<td class="mono rank">{{ index + 1 }}</td> <td class="mono rank">{{ entry.rank }}</td>
<td> <td>
<div class="competitor-cell compact"> <div class="competitor-cell compact">
<img :src="playerImage(entry.row)" :alt="entry.row.nameAr" class="competitor-image" /> <img :src="playerImage(entry.row)" :alt="entry.row.nameAr" class="competitor-image" />
<div> <div>
<p class="name-main">{{ displayName(entry.row) }}</p> <p class="name-main">{{ displayName(entry.row) }}</p>
<p class="name-sub">{{ secondaryName(entry.row) }}</p>
</div> </div>
</div> </div>
</td> </td>
@@ -205,82 +208,36 @@
<h3>{{ t('sections.finalTieTitle') }}</h3> <h3>{{ t('sections.finalTieTitle') }}</h3>
<div class="live-chip-row"> <div class="live-chip-row">
<span class="live-chip">{{ t('table.tieScore') }}</span> <span class="live-chip">{{ t('table.tieScore') }}</span>
<span v-if="tieGroupCountForActiveView > 1" class="live-chip">{{ currentTieGroupPageDisplay }}</span>
</div>
</div>
<Transition name="live-swap" mode="out-in">
<article v-if="activeFinalTieGroup" :key="'live-final-tie-group-' + activeFinalTieGroup.groupKey" class="live-tie-group-card">
<h4 class="live-tie-group-title">{{ t('labels.group') }} {{ activeFinalTieGroup.groupKey }}</h4>
<div class="table-wrap">
<table class="score-table">
<thead>
<tr>
<th>{{ t('table.position') }}</th>
<th>{{ t('table.competitor') }}</th>
<th>{{ t('table.tieScore') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in activeFinalTieGroup.rows" :key="'live-final-tie-' + activeFinalTieGroup.groupKey + '-' + entry.row.playerId">
<td class="mono">{{ entry.position }}</td>
<td>
<div class="competitor-cell compact">
<img :src="playerImage(entry.row)" :alt="entry.row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(entry.row) }}</p>
</div>
</div>
</td>
<td class="mono strong">{{ entry.row.tieBreak }}</td>
</tr>
</tbody>
</table>
</div>
</article>
</Transition>
<div v-if="groupedFinalTieRows.length === 0" class="muted center">{{ t('messages.noFinalTie') }}</div>
</article>
<article v-else-if="activeView === 'final_overall'" class="live-screen-card wide">
<div class="live-screen-head">
<h3>{{ t('sections.finalRanking') }}</h3>
<div class="live-chip-row">
<span v-if="listPageCount > 1" class="live-chip">{{ currentListPageDisplay }}</span> <span v-if="listPageCount > 1" class="live-chip">{{ currentListPageDisplay }}</span>
</div> </div>
</div> </div>
<Transition name="live-swap" mode="out-in"> <Transition name="live-swap" mode="out-in">
<div :key="'final-overall-page-' + listPageIndex" class="live-swap-block"> <div :key="'final-tie-page-' + listPageIndex" class="live-swap-block">
<div class="table-wrap"> <div class="table-wrap">
<table class="score-table"> <table class="score-table">
<thead> <thead>
<tr> <tr>
<th>{{ t('table.rank') }}</th> <th>#</th>
<th>{{ t('table.competitor') }}</th> <th>{{ t('table.competitor') }}</th>
<th>{{ t('table.score') }}</th> <th>{{ t('table.tieScore') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="row in pagedFinalOverallRows" :key="'live-final-overall-' + row.playerId"> <tr v-for="(row, index) in pagedFinalTieRows" :key="'live-final-tie-' + row.playerId">
<td class="mono rank"> <td class="mono">{{ listPageStart + index + 1 }}</td>
<div class="live-rank-cell">
<span>{{ row.rank }}</span>
<span v-if="finalOverallHintLabel(row)" class="live-rank-hint">{{ finalOverallHintLabel(row) }}</span>
</div>
</td>
<td> <td>
<div class="competitor-cell compact"> <div class="competitor-cell compact">
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" /> <img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
<div> <div>
<p class="name-main">{{ displayName(row) }}</p> <p class="name-main">{{ displayName(row) }}</p>
<p class="name-sub">{{ secondaryName(row) }}</p>
</div> </div>
</div> </div>
</td> </td>
<td class="mono strong">{{ row.score }}</td> <td class="mono strong">{{ row.tieBreak }}</td>
</tr> </tr>
<tr v-if="finalOverallRows.length === 0"><td colspan="4" class="muted center">{{ t('labels.noFinalists') }}</td></tr> <tr v-if="finalTieRows.length === 0"><td colspan="3" class="muted center">{{ t('messages.noFinalTie') }}</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -299,7 +256,7 @@
<img class="podium-img" :src="podiumImage(podiumOrdered[0])" :alt="podiumName(podiumOrdered[0])" /> <img class="podium-img" :src="podiumImage(podiumOrdered[0])" :alt="podiumName(podiumOrdered[0])" />
</div> </div>
<div class="podium-medal-icon">🥈</div> <div class="podium-medal-icon">🥈</div>
<h3 class="podium-name" dir="auto" :title="podiumName(podiumOrdered[0])" :class="{ empty: !podiumHasResult(podiumOrdered[0]) }">{{ podiumName(podiumOrdered[0]) }}</h3> <h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[0]) }">{{ podiumName(podiumOrdered[0]) }}</h3>
<div class="podium-score-wrap"> <div class="podium-score-wrap">
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[0]) }}</p> <p class="podium-score">{{ podiumScoreMain(podiumOrdered[0]) }}</p>
<p v-if="podiumTieSubtitle(podiumOrdered[0])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[0]) }}</p> <p v-if="podiumTieSubtitle(podiumOrdered[0])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[0]) }}</p>
@@ -311,7 +268,7 @@
<img class="podium-img" :src="podiumImage(podiumOrdered[1])" :alt="podiumName(podiumOrdered[1])" /> <img class="podium-img" :src="podiumImage(podiumOrdered[1])" :alt="podiumName(podiumOrdered[1])" />
</div> </div>
<div class="podium-medal-icon">🥇</div> <div class="podium-medal-icon">🥇</div>
<h3 class="podium-name" dir="auto" :title="podiumName(podiumOrdered[1])" :class="{ empty: !podiumHasResult(podiumOrdered[1]) }">{{ podiumName(podiumOrdered[1]) }}</h3> <h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[1]) }">{{ podiumName(podiumOrdered[1]) }}</h3>
<div class="podium-score-wrap"> <div class="podium-score-wrap">
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[1]) }}</p> <p class="podium-score">{{ podiumScoreMain(podiumOrdered[1]) }}</p>
<p v-if="podiumTieSubtitle(podiumOrdered[1])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[1]) }}</p> <p v-if="podiumTieSubtitle(podiumOrdered[1])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[1]) }}</p>
@@ -323,7 +280,7 @@
<img class="podium-img" :src="podiumImage(podiumOrdered[2])" :alt="podiumName(podiumOrdered[2])" /> <img class="podium-img" :src="podiumImage(podiumOrdered[2])" :alt="podiumName(podiumOrdered[2])" />
</div> </div>
<div class="podium-medal-icon">🥉</div> <div class="podium-medal-icon">🥉</div>
<h3 class="podium-name" dir="auto" :title="podiumName(podiumOrdered[2])" :class="{ empty: !podiumHasResult(podiumOrdered[2]) }">{{ podiumName(podiumOrdered[2]) }}</h3> <h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[2]) }">{{ podiumName(podiumOrdered[2]) }}</h3>
<div class="podium-score-wrap"> <div class="podium-score-wrap">
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[2]) }}</p> <p class="podium-score">{{ podiumScoreMain(podiumOrdered[2]) }}</p>
<p v-if="podiumTieSubtitle(podiumOrdered[2])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[2]) }}</p> <p v-if="podiumTieSubtitle(podiumOrdered[2])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[2]) }}</p>
@@ -333,24 +290,7 @@
</article> </article>
</div> </div>
</div> </div>
<footer class="live-mode-footer">
<div class="live-footer-slot left ">
<img src="/datwyler_logo.png" alt="Datwyler logo" class=" live-footer-logo live-footer-logo-datwyler " />
</div>
<div class="live-footer-slot center">
<img src="/arture.png" alt="Arture logo" class="live-footer-logo" />
</div>
<div class="live-footer-slot right">
<img src="/simon_logo.png" alt="Simon logo" class="live-footer-logo" />
</div>
</footer>
</section> </section>
</div>
</template> </template>
<script setup> <script setup>
@@ -359,20 +299,18 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
const props = defineProps({ const props = defineProps({
t: { type: Function, required: true }, t: { type: Function, required: true },
liveSettings: { type: Object, required: true }, liveSettings: { type: Object, required: true },
positionBoards: { type: Object, default: () => ({}) },
liveGroupCode: { type: String, default: '' }, liveGroupCode: { type: String, default: '' },
liveGroupMembers: { type: Array, required: true }, liveGroupMembers: { type: Array, required: true },
prelimTie: { type: Object, default: () => ({ required: false, resolved: true, slots: 0, playerIds: [] }) },
prelimTieRows: { type: Array, required: true }, prelimTieRows: { type: Array, required: true },
preliminaryRows: { type: Array, required: true }, preliminaryRows: { type: Array, required: true },
finalists: { type: Array, required: true }, finalists: { type: Array, required: true },
liveFinalGroup: { type: Number, required: true }, liveFinalGroup: { type: Number, required: true },
liveFinalRows: { type: Array, required: true }, liveFinalRows: { type: Array, required: true },
finalOverallRows: { type: Array, required: true },
finalTieRows: { type: Array, required: true }, finalTieRows: { type: Array, required: true },
podiumOrdered: { type: Array, required: true }, podiumOrdered: { type: Array, required: true },
playerImage: { type: Function, required: true }, playerImage: { type: Function, required: true },
displayName: { type: Function, required: true }, displayName: { type: Function, required: true },
secondaryName: { type: Function, required: true },
scoreFor: { type: Function, required: true }, scoreFor: { type: Function, required: true },
prelimTotal: { type: Function, required: true }, prelimTotal: { type: Function, required: true },
finalTotal: { type: Function, required: true }, finalTotal: { type: Function, required: true },
@@ -384,14 +322,9 @@ const props = defineProps({
}) })
const finalistIds = computed(() => new Set((props.finalists || []).map((row) => row.playerId))) const finalistIds = computed(() => new Set((props.finalists || []).map((row) => row.playerId)))
const prelimTieIds = computed(() => new Set((props.prelimTieRows || []).map((row) => row.playerId)))
const finalTieIds = computed(() => new Set((props.finalTieRows || []).map((row) => row.playerId)))
const prelimTieResolved = computed(() => Boolean(props.prelimTie?.resolved))
const activeView = computed(() => props.liveSettings.activeView || 'group_live') const activeView = computed(() => props.liveSettings.activeView || 'group_live')
const listPageIndex = ref(0) const listPageIndex = ref(0)
const tieGroupIndex = ref(0)
let listRotationTimer = null let listRotationTimer = null
let tieRotationTimer = null
const rotationIntervalSec = computed(() => { const rotationIntervalSec = computed(() => {
const raw = Number(props.liveSettings.rotationIntervalSec || 5) const raw = Number(props.liveSettings.rotationIntervalSec || 5)
@@ -407,11 +340,10 @@ const rotationPlayerCount = computed(() => {
return Math.floor(raw) return Math.floor(raw)
}) })
const preliminaryRowsForLive = computed(() => rankPreliminaryRowsWithTieBreak(props.preliminaryRows || []))
const listRowsForActiveView = computed(() => { const listRowsForActiveView = computed(() => {
if (activeView.value === 'prelim_overall') return preliminaryRowsForLive.value if (activeView.value === 'prelim_tie') return props.prelimTieRows || []
if (activeView.value === 'final_overall') return props.finalOverallRows || [] if (activeView.value === 'prelim_overall') return props.preliminaryRows || []
if (activeView.value === 'final_tie') return props.finalTieRows || []
return [] return []
}) })
@@ -421,37 +353,30 @@ const listPageCount = computed(() => {
return Math.max(1, Math.ceil(total / rotationPlayerCount.value)) 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 currentListPageDisplay = computed(() => `${listPageIndex.value + 1} / ${listPageCount.value}`)
const pagedPreliminaryRows = computed(() => paginateRows(preliminaryRowsForLive.value)) const pagedPrelimTieRows = computed(() => paginateRows(props.prelimTieRows || []))
const pagedFinalOverallRows = computed(() => paginateRows(props.finalOverallRows || [])) const pagedPreliminaryRows = computed(() => paginateRows(props.preliminaryRows || []))
const groupedPrelimTieRows = computed(() => groupTieRowsByPositionBoard('prelim_tiebreak', props.prelimTieRows || [])) const pagedFinalTieRows = computed(() => paginateRows(props.finalTieRows || []))
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 rankedLiveGroupMembers = computed(() => {
return (props.liveGroupMembers || []).map((player) => ({ const rows = (props.liveGroupMembers || []).map((player) => ({
player, player,
total: Number(props.prelimTotal(player.id) || 0), 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(() => { const activeViewLabel = computed(() => {
@@ -461,17 +386,15 @@ const activeViewLabel = computed(() => {
prelim_overall: props.t('labels.liveViewPrelimOverall'), prelim_overall: props.t('labels.liveViewPrelimOverall'),
final_groups: props.t('labels.liveViewFinalGroups'), final_groups: props.t('labels.liveViewFinalGroups'),
final_tie: props.t('labels.liveViewFinalTie'), final_tie: props.t('labels.liveViewFinalTie'),
final_overall: props.t('labels.liveViewFinalOverall'),
podium: props.t('labels.liveViewPodium'), podium: props.t('labels.liveViewPodium'),
} }
return map[activeView.value] || map.group_live return map[activeView.value] || map.group_live
}) })
const currentFinalSeedLabel = computed(() => { const currentFinalGroupLabel = computed(() => {
if (props.liveFinalGroup === 2) return props.t('labels.finalGroupSeeds2') if (props.liveFinalGroup === 2) return props.t('labels.finalGroup2')
return props.t('labels.finalGroupSeeds1') return props.t('labels.finalGroup1')
}) })
const currentFinalGroupNumber = computed(() => (props.liveFinalGroup === 2 ? '2' : '1'))
const groupClassKey = computed(() => { const groupClassKey = computed(() => {
const code = String(props.liveGroupCode || '').trim().toUpperCase() const code = String(props.liveGroupCode || '').trim().toUpperCase()
if (code.startsWith('A')) return 'a' if (code.startsWith('A')) return 'a'
@@ -481,48 +404,44 @@ const groupClassKey = computed(() => {
return 'u' return 'u'
}) })
const rankedLiveFinalRows = computed(() => { const rankedLiveFinalRows = computed(() => {
return (props.liveFinalRows || []).map((row) => ({ const rows = (props.liveFinalRows || []).map((row) => ({
row, row,
total: Number(props.finalTotal(row.playerId) || 0), 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) { function isFinalist(playerId) {
return finalistIds.value.has(playerId) return finalistIds.value.has(playerId)
} }
function isPrelimTiePlayer(playerId) {
return prelimTieIds.value.has(playerId)
}
function isFinalTiePlayer(playerId) {
return finalTieIds.value.has(playerId)
}
function prelimOverallHintLabel(row) {
if (!prelimTieResolved.value && isPrelimTiePlayer(row.playerId)) return props.t('labels.tieBreak')
if (isFinalist(row.playerId)) return props.t('labels.finalist')
return ''
}
function finalOverallHintLabel(row) {
if (isFinalTiePlayer(row.playerId)) return props.t('labels.tieBreak')
return ''
}
watch( watch(
() => () =>
`${activeView.value}|${rotationIntervalSec.value}|${rotationPlayerCount.value}|${props.prelimTieRows.length}|${props.preliminaryRows.length}|${props.finalOverallRows.length}|${props.finalTieRows.length}`, `${activeView.value}|${rotationIntervalSec.value}|${rotationPlayerCount.value}|${props.prelimTieRows.length}|${props.preliminaryRows.length}|${props.finalTieRows.length}`,
() => { () => {
restartListRotation() restartListRotation()
restartTieRotation()
}, },
{ immediate: true }, { immediate: true },
) )
onBeforeUnmount(() => { onBeforeUnmount(() => {
stopListRotation() stopListRotation()
stopTieRotation()
}) })
function paginateRows(rows) { function paginateRows(rows) {
@@ -530,36 +449,11 @@ function paginateRows(rows) {
return rows.slice(start, start + rotationPlayerCount.value) return rows.slice(start, start + rotationPlayerCount.value)
} }
function rankPreliminaryRowsWithTieBreak(rows) {
const sorted = [...(rows || [])].sort((a, b) => {
const scoreA = Number(a.score || 0)
const scoreB = Number(b.score || 0)
if (scoreA !== scoreB) return scoreB - scoreA
const tieA = Number(a.tieBreak || 0)
const tieB = Number(b.tieBreak || 0)
if (tieA !== tieB) return tieB - tieA
return Number(a.playerId || 0) - Number(b.playerId || 0)
})
let currentRank = 1
return sorted.map((row, index) => {
if (index > 0) {
const prev = sorted[index - 1]
const isSameScore = Number(prev.score || 0) === Number(row.score || 0)
const isSameTieBreak = Number(prev.tieBreak || 0) === Number(row.tieBreak || 0)
if (!(isSameScore && isSameTieBreak)) {
currentRank = index + 1
}
}
return { ...row, rank: currentRank }
})
}
function restartListRotation() { function restartListRotation() {
stopListRotation() stopListRotation()
listPageIndex.value = 0 listPageIndex.value = 0
const autoRotateView = activeView.value === 'prelim_overall' || activeView.value === 'final_overall' const autoRotateView = activeView.value === 'prelim_tie' || activeView.value === 'prelim_overall' || activeView.value === 'final_tie'
if (!autoRotateView) return if (!autoRotateView) return
if (listPageCount.value <= 1) return if (listPageCount.value <= 1) return
@@ -578,62 +472,4 @@ function stopListRotation() {
listRotationTimer = null 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
}),
}))
}
</script> </script>

View File

@@ -3,9 +3,10 @@
<div class="modal-card"> <div class="modal-card">
<h3 class="modal-title">{{ t('actions.resetScores') }}</h3> <h3 class="modal-title">{{ t('actions.resetScores') }}</h3>
<p class="modal-text">{{ t('messages.confirmReset') }}</p> <p class="modal-text">{{ t('messages.confirmReset') }}</p>
<!-- <p class="modal-text subtle">{{ t('messages.resetProofPrompt') }}</p> --> <p class="modal-text subtle">{{ t('messages.resetProofPrompt') }}</p>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-outline" @click="$emit('confirm', false)">{{ t('actions.resetOnlyScores') }}</button> <button class="btn btn-outline" @click="$emit('confirm', false)">{{ t('actions.resetOnlyScores') }}</button>
<button class="btn btn-danger" @click="$emit('confirm', true)">{{ t('actions.resetScoresAndProofs') }}</button>
<button class="btn btn-secondary" @click="$emit('cancel')">{{ t('actions.cancel') }}</button> <button class="btn btn-secondary" @click="$emit('cancel')">{{ t('actions.cancel') }}</button>
</div> </div>
</div> </div>

View File

@@ -12,7 +12,6 @@
<option value="prelim_overall">{{ t('labels.liveViewPrelimOverall') }}</option> <option value="prelim_overall">{{ t('labels.liveViewPrelimOverall') }}</option>
<option value="final_groups">{{ t('labels.liveViewFinalGroups') }}</option> <option value="final_groups">{{ t('labels.liveViewFinalGroups') }}</option>
<option value="final_tie">{{ t('labels.liveViewFinalTie') }}</option> <option value="final_tie">{{ t('labels.liveViewFinalTie') }}</option>
<option value="final_overall">{{ t('labels.liveViewFinalOverall') }}</option>
<option value="podium">{{ t('labels.liveViewPodium') }}</option> <option value="podium">{{ t('labels.liveViewPodium') }}</option>
</select> </select>
</div> </div>
@@ -78,126 +77,19 @@
@change="emitPatch({ rotationPlayerCount: Number($event.target.value) || 12 })" @change="emitPatch({ rotationPlayerCount: Number($event.target.value) || 12 })"
/> />
</div> </div>
<hr class="live-settings-divider" />
<div class="live-settings-row">
<label class="control-label">{{ t('labels.stageControl') }}</label>
<div class="stage-control-grid">
<div class="stage-select-card">
<p class="stage-select-label">{{ t('labels.stagePhase') }}</p>
<select class="name-input" :value="stageControl.phase" @change="updateStageControl({ phase: $event.target.value })">
<option value="preliminary">{{ t('labels.phasePreliminary') }}</option>
<option value="prelim_tiebreak">{{ t('labels.phasePrelimTie') }}</option>
<option value="final">{{ t('labels.phaseFinal') }}</option>
<option value="final_tiebreak">{{ t('labels.phaseFinalTie') }}</option>
</select>
</div>
<div class="stage-select-card">
<p class="stage-select-label">{{ t('labels.stageGroup') }}</p>
<select class="name-input" :value="stageControl.group" @change="updateStageControl({ group: $event.target.value })" :disabled="groupOptions.length === 0">
<option v-if="groupOptions.length === 0" value="">{{ t('messages.noGroupForPhase') }}</option>
<option v-for="group in groupOptions" :key="'stage-group-' + stageControl.phase + '-' + group" :value="group">{{ group }}</option>
</select>
</div>
<div class="stage-select-card">
<p class="stage-select-label">{{ t('labels.stageRound') }}</p>
<select class="name-input" :value="String(stageControl.round)" @change="updateStageControl({ round: Number($event.target.value) || 1 })">
<option v-for="round in roundOptions" :key="'stage-round-' + stageControl.phase + '-' + round" :value="String(round)">
{{ round }}
</option>
</select>
</div>
</div>
<p class="stage-selection-preview">{{ selectedStageSummary }}</p>
<div class="panel-actions compact stage-action-row">
<button class="btn btn-primary stage-start-btn" @click="$emit('start-stage')" :disabled="groupOptions.length === 0">
{{ t('actions.startStage') }}
</button>
<button class="btn btn-outline" @click="$emit('end-stage')">{{ t('actions.endStage') }}</button>
</div>
</div>
<div class="stage-status-grid">
<div class="stage-status-card">
<p class="stage-status-title">{{ t('labels.currentStage') }}</p>
<template v-if="currentStage">
<p class="stage-status-main">{{ formatStageMain(currentStage) }}</p>
<p class="stage-status-sub">{{ t('labels.startedAt') }}: {{ currentStage.startedAt || '-' }}</p>
<div class="panel-actions compact stage-status-actions">
<button class="btn btn-primary btn-xs" @click="$emit('navigate-current-stage')">{{ t('actions.goToStageScoring') }}</button>
</div>
</template>
<p v-else class="stage-status-empty">{{ t('labels.noCurrentStage') }}</p>
</div>
<div class="stage-status-card">
<p class="stage-status-title">{{ t('labels.lastStage') }}</p>
<template v-if="lastStage">
<p class="stage-status-main">{{ formatStageMain(lastStage) }}</p>
<p class="stage-status-sub">{{ t('labels.startedAt') }}: {{ lastStage.startedAt || '-' }}</p>
<p v-if="lastStage.endedAt" class="stage-status-sub">{{ t('labels.endedAt') }}: {{ lastStage.endedAt }}</p>
</template>
<p v-else class="stage-status-empty">{{ t('labels.noLastStage') }}</p>
</div>
</div>
</section> </section>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'
const props = defineProps({ const props = defineProps({
t: { type: Function, required: true }, t: { type: Function, required: true },
liveSettings: { type: Object, required: true }, liveSettings: { type: Object, required: true },
assignableGroups: { type: Array, required: true }, assignableGroups: { type: Array, required: true },
stageControl: { type: Object, required: true },
stageOptions: { type: Object, required: true },
currentStage: { type: Object, default: null },
lastStage: { type: Object, default: null },
}) })
const emit = defineEmits(['update-live-setting', 'update-stage-control', 'start-stage', 'end-stage', 'navigate-current-stage']) const emit = defineEmits(['update-live-setting'])
const groupOptions = computed(() => {
const key = String(props.stageControl.phase || '').trim().toLowerCase()
const list = props.stageOptions?.[key]
return Array.isArray(list) ? list : []
})
const roundOptions = computed(() => {
const phase = String(props.stageControl.phase || '').trim().toLowerCase()
if (phase === 'preliminary') return [1, 2, 3]
if (phase === 'final') return [1, 2]
return [1]
})
const selectedStageSummary = computed(() => {
const phase = formatPhase(props.stageControl.phase)
const group = String(props.stageControl.group || '').trim() || '-'
const round = Number(props.stageControl.round || 1)
return `${props.t('labels.stagePhase')}: ${phase} · ${props.t('labels.stageGroup')}: ${group} · ${props.t('labels.stageRound')}: ${round}`
})
function emitPatch(patch) { function emitPatch(patch) {
emit('update-live-setting', patch) emit('update-live-setting', patch)
} }
function updateStageControl(patch) {
emit('update-stage-control', patch)
}
function formatPhase(phase) {
const normalized = String(phase || '').trim().toLowerCase()
if (normalized === 'preliminary') return props.t('labels.phasePreliminary')
if (normalized === 'prelim_tiebreak') return props.t('labels.phasePrelimTie')
if (normalized === 'final') return props.t('labels.phaseFinal')
if (normalized === 'final_tiebreak') return props.t('labels.phaseFinalTie')
return normalized || '-'
}
function formatStageMain(stage) {
if (!stage) return '-'
const group = String(stage.group || '').trim() || '-'
const round = Number(stage.round || 1)
return `${formatPhase(stage.phase)} · ${props.t('labels.stageGroup')} ${group} · ${props.t('labels.stageRound')} ${round}`
}
</script> </script>

View File

@@ -9,20 +9,6 @@
/> />
</div> </div>
<div
v-for="section in groupedSections"
:key="'multi-section-' + section.key"
class="score-section-block"
:class="{ 'score-section-overflow': isOverflow(section) }"
:style="section.style"
>
<button class="score-section-toggle" type="button" @click="toggleSection(section.key)">
<span class="score-section-title">{{ section.label }}</span>
<span class="score-section-meta">{{ section.rows.length }} {{ isCollapsed(section.key) ? '+' : '' }}</span>
</button>
<div v-if="isOverflow(section)" class="score-section-alert">{{ section.label }} > {{ overflowLimit }}</div>
<template v-if="!isCollapsed(section.key)">
<div class="table-wrap desktop-score-table"> <div class="table-wrap desktop-score-table">
<table class="score-table"> <table class="score-table">
<thead> <thead>
@@ -32,19 +18,13 @@
<th v-if="showGroup">{{ t('table.group') }}</th> <th v-if="showGroup">{{ t('table.group') }}</th>
<th v-if="showRank">{{ t('table.rank') }}</th> <th v-if="showRank">{{ t('table.rank') }}</th>
<th v-if="showSeed">{{ t('table.seed') }}</th> <th v-if="showSeed">{{ t('table.seed') }}</th>
<th <th v-for="round in roundStages" :key="'head-' + round.stage">{{ round.label }}</th>
v-for="round in roundStages"
:key="'head-' + round.stage"
:class="{ 'stage-column-highlight': isHighlightedStage(round.stage, section) }"
>
{{ round.label }}
</th>
<th>{{ t('table.total') }}</th> <th>{{ t('table.total') }}</th>
<th v-if="showProofControls">{{ t('table.verification') }}</th> <th>{{ t('table.verification') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(row, index) in section.rows" :key="'multi-row-' + section.key + '-' + row.playerId" class="score-group-row" :style="scoreGroupStyleFor(row, section)"> <tr v-for="(row, index) in filteredRows" :key="'multi-row-' + row.playerId" class="score-group-row" :style="scoreGroupStyleFor(row)">
<td class="mono">{{ index + 1 }}</td> <td class="mono">{{ index + 1 }}</td>
<td> <td>
<div class="competitor-cell compact"> <div class="competitor-cell compact">
@@ -58,20 +38,14 @@
<td v-if="showGroup" class="mono">{{ groupCell(row) }}</td> <td v-if="showGroup" class="mono">{{ groupCell(row) }}</td>
<td v-if="showRank" class="mono rank">{{ row.rank }}</td> <td v-if="showRank" class="mono rank">{{ row.rank }}</td>
<td v-if="showSeed" class="mono">{{ row.seed }}</td> <td v-if="showSeed" class="mono">{{ row.seed }}</td>
<td <td v-for="round in roundStages" :key="'cell-' + round.stage + '-' + row.playerId" class="multi-round-cell">
v-for="round in roundStages"
:key="'cell-' + round.stage + '-' + row.playerId"
class="multi-round-cell"
:class="{ 'stage-column-highlight': isHighlightedStage(round.stage, section) }"
>
<input <input
class="score-input score-input-compact" class="score-input score-input-compact"
:class="{ 'score-input-over-limit': scoreInputInvalid(round.stage, row.playerId), 'score-input-stage-highlight': isHighlightedStage(round.stage, section) }"
type="number" type="number"
inputmode="numeric" inputmode="numeric"
pattern="[0-9]*" pattern="[0-9]*"
min="0" min="0"
max="100" max="9999"
:value="scoreInputValue(round.stage, row.playerId)" :value="scoreInputValue(round.stage, row.playerId)"
@focus="onScoreFocus(round.stage, row.playerId)" @focus="onScoreFocus(round.stage, row.playerId)"
@input="onScoreInput(round.stage, row.playerId, $event)" @input="onScoreInput(round.stage, row.playerId, $event)"
@@ -80,7 +54,7 @@
/> />
</td> </td>
<td class="mono strong">{{ totalScore(row.playerId) }}</td> <td class="mono strong">{{ totalScore(row.playerId) }}</td>
<td v-if="showProofControls" class="verify-round-cell"> <td class="verify-round-cell">
<div class="verify-round-list"> <div class="verify-round-list">
<div v-for="round in roundStages" :key="'verify-' + round.stage + '-' + row.playerId" class="verify-round-item"> <div v-for="round in roundStages" :key="'verify-' + round.stage + '-' + row.playerId" class="verify-round-item">
<span class="verify-round-label">{{ round.label }}</span> <span class="verify-round-label">{{ round.label }}</span>
@@ -98,12 +72,15 @@
</div> </div>
</td> </td>
</tr> </tr>
<tr v-if="filteredRows.length === 0">
<td :colspan="columnCount" class="muted center">{{ t('labels.noPlayers') }}</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="mobile-score-cards"> <div class="mobile-score-cards">
<article v-for="(row, index) in section.rows" :key="'multi-mobile-' + section.key + '-' + row.playerId" class="score-card score-group-card" :style="scoreGroupStyleFor(row, section)"> <article v-for="(row, index) in filteredRows" :key="'multi-mobile-' + row.playerId" class="score-card score-group-card" :style="scoreGroupStyleFor(row)">
<div class="score-card-head"> <div class="score-card-head">
<div class="competitor-cell compact"> <div class="competitor-cell compact">
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" /> <img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
@@ -122,21 +99,15 @@
</div> </div>
<div class="mobile-round-grid"> <div class="mobile-round-grid">
<label <label v-for="round in roundStages" :key="'mobile-round-' + round.stage + '-' + row.playerId" class="mobile-round-item">
v-for="round in roundStages"
:key="'mobile-round-' + round.stage + '-' + row.playerId"
class="mobile-round-item"
:class="{ 'stage-column-highlight': isHighlightedStage(round.stage, section) }"
>
<span class="score-label">{{ round.label }}</span> <span class="score-label">{{ round.label }}</span>
<input <input
class="score-input" class="score-input"
:class="{ 'score-input-over-limit': scoreInputInvalid(round.stage, row.playerId), 'score-input-stage-highlight': isHighlightedStage(round.stage, section) }"
type="number" type="number"
inputmode="numeric" inputmode="numeric"
pattern="[0-9]*" pattern="[0-9]*"
min="0" min="0"
max="100" max="9999"
:value="scoreInputValue(round.stage, row.playerId)" :value="scoreInputValue(round.stage, row.playerId)"
@focus="onScoreFocus(round.stage, row.playerId)" @focus="onScoreFocus(round.stage, row.playerId)"
@input="onScoreInput(round.stage, row.playerId, $event)" @input="onScoreInput(round.stage, row.playerId, $event)"
@@ -151,7 +122,7 @@
<strong class="mono">{{ totalScore(row.playerId) }}</strong> <strong class="mono">{{ totalScore(row.playerId) }}</strong>
</div> </div>
<div v-if="showProofControls" class="mobile-verify-block"> <div class="mobile-verify-block">
<p class="mobile-verify-title">{{ t('table.verification') }}</p> <p class="mobile-verify-title">{{ t('table.verification') }}</p>
<div class="verify-round-list mobile"> <div class="verify-round-list mobile">
<div v-for="round in roundStages" :key="'mobile-verify-' + round.stage + '-' + row.playerId" class="verify-round-item"> <div v-for="round in roundStages" :key="'mobile-verify-' + round.stage + '-' + row.playerId" class="verify-round-item">
@@ -170,18 +141,14 @@
</div> </div>
</div> </div>
</article> </article>
<div v-if="filteredRows.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
</div> </div>
</template>
</div>
<div v-if="groupedSections.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed } from 'vue'
import { groupThemeStyleForKey, scoreGroupStyle } from '../../utils/scoreGroupTheme' import { scoreGroupStyle } from '../../utils/scoreGroupTheme'
import { normalizedGroupCode } from '../../utils/groups'
const props = defineProps({ const props = defineProps({
t: { type: Function, required: true }, t: { type: Function, required: true },
@@ -193,14 +160,10 @@ const props = defineProps({
showGroup: { type: Boolean, default: false }, showGroup: { type: Boolean, default: false },
showRank: { type: Boolean, default: false }, showRank: { type: Boolean, default: false },
showSeed: { type: Boolean, default: false }, showSeed: { type: Boolean, default: false },
sectionByGroup: { type: Boolean, default: false },
overflowLimit: { type: Number, default: 0 },
showProofControls: { type: Boolean, default: true },
playerImage: { type: Function, required: true }, playerImage: { type: Function, required: true },
displayName: { type: Function, required: true }, displayName: { type: Function, required: true },
secondaryName: { type: Function, required: true }, secondaryName: { type: Function, required: true },
scoreInputValue: { type: Function, required: true }, scoreInputValue: { type: Function, required: true },
scoreInputInvalid: { type: Function, required: true },
onScoreFocus: { type: Function, required: true }, onScoreFocus: { type: Function, required: true },
onScoreInput: { type: Function, required: true }, onScoreInput: { type: Function, required: true },
onScoreCommit: { type: Function, required: true }, onScoreCommit: { type: Function, required: true },
@@ -208,14 +171,10 @@ const props = defineProps({
scoreProofFor: { type: Function, required: true }, scoreProofFor: { type: Function, required: true },
openScoreProofUploader: { type: Function, required: true }, openScoreProofUploader: { type: Function, required: true },
openProofPreview: { type: Function, required: true }, openProofPreview: { type: Function, required: true },
highlightStage: { type: String, default: '' },
highlightGroup: { type: String, default: '' },
}) })
defineEmits(['update:filter']) defineEmits(['update:filter'])
const collapsedSections = ref({})
const filteredRows = computed(() => { const filteredRows = computed(() => {
const query = String(props.filterText || '').trim().toLowerCase() const query = String(props.filterText || '').trim().toLowerCase()
if (!query) return props.rows if (!query) return props.rows
@@ -226,144 +185,22 @@ const filteredRows = computed(() => {
}) })
}) })
const groupedSections = computed(() => { const columnCount = computed(() => {
if (!props.sectionByGroup) { let count = 4 + props.roundStages.length
return [ if (props.showGroup) count += 1
{ if (props.showRank) count += 1
key: 'all', if (props.showSeed) count += 1
label: props.t('labels.allPlayers'), return count
rows: filteredRows.value,
style: null,
},
].filter((section) => section.rows.length > 0)
}
const buckets = new Map()
for (const row of filteredRows.value) {
const key = sectionKeyForRow(row)
if (!buckets.has(key)) {
buckets.set(key, {
key,
label: sectionLabelForKey(key),
rows: [],
}) })
}
buckets.get(key).rows.push(row)
}
return [...buckets.values()]
.sort((a, b) => sectionSortValue(a.key) - sectionSortValue(b.key) || String(a.key).localeCompare(String(b.key)))
.map((section) => ({
...section,
style: sectionStyleFor(section.key, section.rows[0]),
}))
})
watch(
groupedSections,
(sections) => {
const next = {}
for (const section of sections) {
next[section.key] = collapsedSections.value[section.key] || false
}
collapsedSections.value = next
},
{ immediate: true },
)
function sectionKeyForRow(row) {
const boardGroup = String(row?._boardGroupKey || '').trim()
if (boardGroup) return `B${boardGroup}`
const finalGroup = Number.parseInt(String(row?.finalGroup || ''), 10)
if (Number.isFinite(finalGroup) && finalGroup > 0) return `F${finalGroup}`
const group = normalizedGroupCode(row?.groupCode || '')
if (group) return `G${group}`
return 'U'
}
function sectionLabelForKey(key) {
if (key === 'U') return `${props.t('labels.group')} ${props.t('labels.unassigned')}`
if (key.startsWith('B')) return `${props.t('labels.group')} ${key.slice(1)}`
if (key.startsWith('F')) return `${props.t('labels.group')} ${key.slice(1)}`
if (key.startsWith('G')) return `${props.t('labels.group')} ${key.slice(1)}`
return `${props.t('labels.group')} ${key}`
}
function sectionSortValue(key) {
if (key === 'U') return 9999
if (key.startsWith('B')) {
const raw = key.slice(1)
const parsed = Number.parseInt(raw, 10)
if (Number.isFinite(parsed) && parsed > 0) return parsed
const code = String(raw).toUpperCase()
if (code === 'A') return 1
if (code === 'B') return 2
if (code === 'C') return 3
if (code === 'D') return 4
return 5000
}
if (key.startsWith('F')) return Number.parseInt(key.slice(1), 10) || 9998
if (key.startsWith('G')) {
const code = String(key.slice(1)).toUpperCase()
if (code === 'A') return 1
if (code === 'B') return 2
if (code === 'C') return 3
if (code === 'D') return 4
}
return 5000
}
function groupCell(row) { function groupCell(row) {
if (row?._boardGroupKey) return row._boardGroupKey
if (row.finalGroup === 1 || row.finalGroup === 2) { if (row.finalGroup === 1 || row.finalGroup === 2) {
return row.finalGroup return row.finalGroup
} }
return row.groupCode || props.t('labels.unassigned') return row.groupCode || props.t('labels.unassigned')
} }
function sectionStyleFor(key, sampleRow) { function scoreGroupStyleFor(row) {
if (key.startsWith('B')) return groupThemeStyleForKey(key)
return scoreGroupStyle(sampleRow)
}
function scoreGroupStyleFor(row, section) {
if (section?.key?.startsWith('B') && section?.style) return section.style
return scoreGroupStyle(row) return scoreGroupStyle(row)
} }
function isCollapsed(sectionKey) {
return Boolean(collapsedSections.value[sectionKey])
}
function toggleSection(sectionKey) {
collapsedSections.value[sectionKey] = !collapsedSections.value[sectionKey]
}
function isOverflow(section) {
const limit = Number(props.overflowLimit || 0)
if (!Number.isFinite(limit) || limit <= 0) return false
return Number(section?.rows?.length || 0) > limit
}
function isHighlightedStage(stage, section) {
const stageMatch = String(props.highlightStage || '').trim().toLowerCase() === String(stage || '').trim().toLowerCase()
if (!stageMatch) return false
const wanted = String(props.highlightGroup || '').trim().toUpperCase()
if (!wanted) return true
return sectionGroupKey(section) === wanted
}
function sectionGroupKey(section) {
const key = String(section?.key || '').trim()
if (!key) return ''
if (key === 'U') return 'UNASSIGNED'
if (key.startsWith('B') || key.startsWith('F') || key.startsWith('G') || key.startsWith('T')) {
return String(key.slice(1)).trim().toUpperCase()
}
return key.toUpperCase()
}
</script> </script>

View File

@@ -83,15 +83,10 @@
<article v-for="group in groupedCards" :key="'group-card-' + group.code" class="group-player-card" :class="'group-' + group.key"> <article v-for="group in groupedCards" :key="'group-card-' + group.code" class="group-player-card" :class="'group-' + group.key">
<header class="group-player-card-head"> <header class="group-player-card-head">
<h3>{{ group.label }}</h3> <h3>{{ group.label }}</h3>
<div class="group-card-head-actions">
<span class="pm-count-badge mono">{{ group.players.length }}</span> <span class="pm-count-badge mono">{{ group.players.length }}</span>
<button class="group-card-toggle" type="button" @click="toggleGroup(group.code)">
{{ isGroupCollapsed(group.code) ? '+' : '' }}
</button>
</div>
</header> </header>
<div class="group-player-list" v-if="group.players.length > 0 && !isGroupCollapsed(group.code)"> <div class="group-player-list" v-if="group.players.length > 0">
<div class="group-player-row" v-for="player in group.players" :key="'card-player-' + player.id"> <div class="group-player-row" v-for="player in group.players" :key="'card-player-' + player.id">
<div class="group-player-top"> <div class="group-player-top">
<div class="competitor-cell"> <div class="competitor-cell">
@@ -136,14 +131,14 @@
</div> </div>
</div> </div>
<div v-else-if="group.players.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div> <div v-else class="empty-state">{{ t('labels.noPlayers') }}</div>
</article> </article>
</div> </div>
</section> </section>
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed } from 'vue'
const props = defineProps({ const props = defineProps({
t: { type: Function, required: true }, t: { type: Function, required: true },
@@ -232,29 +227,6 @@ const groupedCards = computed(() => {
return cards return cards
}) })
const collapsedGroups = ref({})
watch(
groupedCards,
(cards) => {
const next = {}
for (const card of cards) {
next[card.code || 'U'] = collapsedGroups.value[card.code || 'U'] || false
}
collapsedGroups.value = next
},
{ immediate: true },
)
function isGroupCollapsed(code) {
return Boolean(collapsedGroups.value[code || 'U'])
}
function toggleGroup(code) {
const key = code || 'U'
collapsedGroups.value[key] = !collapsedGroups.value[key]
}
function comparePlayers(a, b, sort) { function comparePlayers(a, b, sort) {
if (sort === 'nameAr') { if (sort === 'nameAr') {
return String(a.nameAr || '').localeCompare(String(b.nameAr || ''), 'ar') || a.id - b.id return String(a.nameAr || '').localeCompare(String(b.nameAr || ''), 'ar') || a.id - b.id

View File

@@ -1,249 +0,0 @@
<template>
<div class="position-board">
<div class="position-board-head">
<button class="position-board-toggle" type="button" @click="collapsedAll = !collapsedAll">
<span class="position-board-title">{{ t('labels.groupAssignment') }}</span>
<span class="position-board-meta">{{ columns.length }} {{ collapsedAll ? '+' : '' }}</span>
</button>
<div v-if="hasOverflowColumns" class="position-board-alert">{{ t('labels.group') }} > 6</div>
</div>
<div v-if="!collapsedAll" class="position-board-grid">
<section
v-for="column in columns"
:key="board + '-col-' + column.key"
class="position-column"
:class="{ 'position-column-overflow': shouldHighlightOverflow && column.items.length > 6 }"
>
<header class="position-column-head">
<div class="position-column-toggle">
<span class="position-column-title">{{ columnLabel(column.key) }}</span>
<span class="position-column-count" :class="{ danger: shouldHighlightOverflow && column.items.length > 6 }">{{ column.items.length }}</span>
</div>
<div v-if="shouldHighlightOverflow && column.items.length > 6" class="position-column-alert">
{{ t('labels.group') }} {{ column.key }} > 6
</div>
</header>
<div class="position-column-list" @dragover.prevent @drop="onDropAtEnd(column.key)">
<article
v-for="(item, index) in column.items"
:key="board + '-card-' + item.playerId"
class="position-card"
draggable="true"
@dragstart="onDragStart($event, column.key, index, item.playerId)"
@dragover.prevent
@drop="onDropBefore(column.key, index)"
>
<div class="position-badge">{{ index + 1 }}</div>
<img :src="playerImage(item)" :alt="item.nameAr" class="position-avatar" />
<div class="position-card-text">
<p class="name-main">{{ displayName(item) }}</p>
<p class="name-sub">{{ secondaryName(item) }}</p>
<p v-if="scoreText(item)" class="position-score">{{ scoreText(item) }}</p>
</div>
</article>
<div v-if="column.items.length === 0" class="position-empty" @dragover.prevent @drop="onDropAtEnd(column.key)">
{{ t('labels.noPlayers') }}
</div>
</div>
</section>
</div>
<div class="position-actions" v-if="isNumericBoard">
<button class="btn btn-outline btn-xs" @click="addNumericGroup">+ {{ t('labels.group') }}</button>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({
t: { type: Function, required: true },
board: { type: String, required: true },
rows: { type: Array, required: true },
slots: { type: Object, default: () => ({}) },
playerImage: { type: Function, required: true },
displayName: { type: Function, required: true },
secondaryName: { type: Function, required: true },
groupLabelPrefix: { type: String, default: '' },
numericGroups: { type: Boolean, default: false },
availableGroups: { type: Array, default: () => [] },
})
const emit = defineEmits(['save'])
const columns = ref([])
const dragging = ref(null)
const collapsedAll = ref(false)
const isNumericBoard = computed(() => props.numericGroups)
const shouldHighlightOverflow = computed(
() =>
props.board === 'preliminary' ||
props.board === 'final' ||
props.board === 'prelim_tiebreak' ||
props.board === 'final_tiebreak',
)
const hasOverflowColumns = computed(
() => shouldHighlightOverflow.value && columns.value.some((column) => Number(column?.items?.length || 0) > 6),
)
watch(
() => `${props.board}|${props.rows.map((row) => row.playerId).join('|')}|${JSON.stringify(props.slots || {})}`,
() => {
rebuildColumns()
},
{ immediate: true },
)
function rebuildColumns() {
const mapped = (props.rows || []).map((row, idx) => {
const slot = props.slots?.[String(row.playerId)] || props.slots?.[row.playerId] || null
return {
...row,
slotGroup: normalizeGroup(slot?.groupKey, row, idx),
slotPosition: normalizePosition(slot?.position, idx + 1),
}
})
const byGroup = new Map()
for (const item of mapped) {
if (!byGroup.has(item.slotGroup)) byGroup.set(item.slotGroup, [])
byGroup.get(item.slotGroup).push(item)
}
const groupKeys = [...byGroup.keys()].sort(compareGroupKeys)
if (!isNumericBoard.value) {
for (const group of props.availableGroups || []) {
const normalized = String(group || '').trim().toUpperCase()
if (!normalized) continue
if (!groupKeys.includes(normalized)) groupKeys.push(normalized)
}
groupKeys.sort(compareGroupKeys)
}
let nextColumns = groupKeys.map((key) => {
const items = byGroup.get(key)
.slice()
.sort((a, b) => {
if (a.slotPosition !== b.slotPosition) return a.slotPosition - b.slotPosition
return a.playerId - b.playerId
})
return { key, items }
})
columns.value = nextColumns
}
function normalizeGroup(groupKey, row, idx) {
if (isNumericBoard.value) {
const parsed = Number.parseInt(String(groupKey || ''), 10)
if (Number.isFinite(parsed) && parsed > 0) return String(parsed)
const rowScoreGroup = Number.parseInt(String(row?.scoreGroup || ''), 10)
if (Number.isFinite(rowScoreGroup) && rowScoreGroup > 0) return String(rowScoreGroup)
const rowFinalGroup = Number.parseInt(String(row?.finalGroup || ''), 10)
if (Number.isFinite(rowFinalGroup) && rowFinalGroup > 0) return String(rowFinalGroup)
return '1'
}
const value = String(groupKey || row.groupCode || '').trim().toUpperCase()
return value || 'UNASSIGNED'
}
function normalizePosition(position, fallback) {
const parsed = Number(position)
if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed)
return fallback
}
function compareGroupKeys(a, b) {
if (isNumericBoard.value) {
return Number(a) - Number(b)
}
if (a === 'UNASSIGNED') return 1
if (b === 'UNASSIGNED') return -1
return a.localeCompare(b)
}
function columnLabel(key) {
if (isNumericBoard.value) {
return `${props.t('labels.group')} ${key}`
}
if (key === 'UNASSIGNED') return props.t('labels.unassigned')
return `${props.groupLabelPrefix || props.t('labels.group')} ${key}`
}
function scoreText(item) {
if (props.board === 'preliminary') return `${props.t('table.total')}: ${Number(item.score || 0)}`
if (props.board === 'final') return `${props.t('table.total')}: ${Number(item.score || 0)}`
return `${props.t('table.tieScore')}: ${Number(item.tieBreak || 0)}`
}
function onDragStart(event, groupKey, index, playerId) {
dragging.value = { groupKey, index, playerId }
event.dataTransfer.effectAllowed = 'move'
}
function onDropBefore(targetGroupKey, targetIndex) {
moveDragged(targetGroupKey, targetIndex)
}
function onDropAtEnd(targetGroupKey) {
const column = columns.value.find((entry) => entry.key === targetGroupKey)
const targetIndex = column ? column.items.length : 0
moveDragged(targetGroupKey, targetIndex)
}
function moveDragged(targetGroupKey, targetIndex) {
const drag = dragging.value
if (!drag) return
const sourceCol = columns.value.find((entry) => entry.key === drag.groupKey)
const targetCol = columns.value.find((entry) => entry.key === targetGroupKey)
if (!sourceCol || !targetCol) {
dragging.value = null
return
}
const sourceIndex = sourceCol.items.findIndex((item) => item.playerId === drag.playerId)
if (sourceIndex < 0) {
dragging.value = null
return
}
const [moved] = sourceCol.items.splice(sourceIndex, 1)
let insertAt = targetIndex
if (sourceCol.key === targetCol.key && sourceIndex < targetIndex) {
insertAt -= 1
}
if (insertAt < 0) insertAt = 0
if (insertAt > targetCol.items.length) insertAt = targetCol.items.length
targetCol.items.splice(insertAt, 0, moved)
dragging.value = null
emitSave()
}
function emitSave() {
const slots = []
for (const column of columns.value) {
column.items.forEach((item, idx) => {
slots.push({
playerId: item.playerId,
groupKey: column.key,
position: idx + 1,
})
})
}
emit('save', { board: props.board, slots })
}
function addNumericGroup() {
if (!isNumericBoard.value) return
const keys = columns.value.map((entry) => Number(entry.key)).filter((value) => Number.isFinite(value) && value > 0)
const next = keys.length > 0 ? Math.max(...keys) + 1 : 1
columns.value = [...columns.value, { key: String(next), items: [] }]
}
</script>

View File

@@ -9,13 +9,6 @@
/> />
</div> </div>
<div v-for="section in groupedSections" :key="'section-' + stage + '-' + section.key" class="score-section-block" :class="{ 'tie-break': tieBreakSectionMode }" :style="section.style">
<button class="score-section-toggle" type="button" @click="toggleSection(section.key)">
<span class="score-section-title">{{ section.label }}</span>
<span class="score-section-meta">{{ section.rows.length }} {{ isCollapsed(section.key) ? '+' : '' }}</span>
</button>
<template v-if="!isCollapsed(section.key)">
<div class="table-wrap desktop-score-table"> <div class="table-wrap desktop-score-table">
<table class="score-table"> <table class="score-table">
<thead> <thead>
@@ -24,19 +17,14 @@
<th>{{ t('table.competitor') }}</th> <th>{{ t('table.competitor') }}</th>
<th v-if="showGroup">{{ t('table.group') }}</th> <th v-if="showGroup">{{ t('table.group') }}</th>
<th v-if="showScoreBeforeInput">{{ t('table.score') }}</th> <th v-if="showScoreBeforeInput">{{ t('table.score') }}</th>
<th :class="{ 'stage-column-highlight': isHighlightedStage(stage, section) }">{{ inputLabel }}</th> <th>{{ inputLabel }}</th>
<th v-if="showProofControls">{{ t('table.verification') }}</th> <th>{{ t('table.verification') }}</th>
<th v-if="showRank">{{ t('table.rank') }}</th> <th v-if="showRank">{{ t('table.rank') }}</th>
<th v-if="showSeed">{{ t('table.seed') }}</th> <th v-if="showSeed">{{ t('table.seed') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr v-for="(row, index) in filteredRows" :key="'desk-' + stage + '-' + row.playerId" class="score-group-row" :style="scoreGroupStyleFor(row)">
v-for="(row, index) in section.rows"
:key="'desk-' + stage + '-' + section.key + '-' + row.playerId"
class="score-group-row"
:style="scoreRowStyleFor(row, section)"
>
<td class="mono">{{ index + 1 }}</td> <td class="mono">{{ index + 1 }}</td>
<td> <td>
<div class="competitor-cell compact"> <div class="competitor-cell compact">
@@ -49,15 +37,14 @@
</td> </td>
<td v-if="showGroup" class="mono">{{ row.groupCode || t('labels.unassigned') }}</td> <td v-if="showGroup" class="mono">{{ row.groupCode || t('labels.unassigned') }}</td>
<td v-if="showScoreBeforeInput" class="mono strong">{{ row.score }}</td> <td v-if="showScoreBeforeInput" class="mono strong">{{ row.score }}</td>
<td :class="{ 'stage-column-highlight': isHighlightedStage(stage, section) }"> <td>
<input <input
class="score-input" class="score-input"
:class="{ 'score-input-over-limit': scoreInputInvalid(stage, row.playerId), 'score-input-stage-highlight': isHighlightedStage(stage, section) }"
type="number" type="number"
inputmode="numeric" inputmode="numeric"
pattern="[0-9]*" pattern="[0-9]*"
min="0" min="0"
max="100" max="9999"
:value="scoreInputValue(stage, row.playerId)" :value="scoreInputValue(stage, row.playerId)"
@focus="onScoreFocus(stage, row.playerId)" @focus="onScoreFocus(stage, row.playerId)"
@input="onScoreInput(stage, row.playerId, $event)" @input="onScoreInput(stage, row.playerId, $event)"
@@ -65,21 +52,42 @@
@keydown.enter.prevent="onScoreCommit(stage, row.playerId)" @keydown.enter.prevent="onScoreCommit(stage, row.playerId)"
/> />
</td> </td>
<td>
<div class="proof-actions">
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(stage, row.playerId)">
{{ hasScoreProof(stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
</button>
<!-- <button
v-if="hasScoreProof(stage, row.playerId)"
class="btn btn-ai btn-xs"
@click="requestScoreAdvice(stage, row.playerId)"
>
{{ t('actions.aiAdvisor') }}
</button> -->
<button v-if="hasScoreProof(stage, row.playerId)" class="btn btn-danger btn-xs" @click="removeScoreProof(stage, row.playerId)">
{{ t('actions.removeProof') }}
</button>
<img
v-if="hasScoreProof(stage, row.playerId)"
class="proof-thumb"
:src="scoreProofFor(stage, row.playerId)"
:alt="t('table.verification')"
@click="openProofPreview(stage, row.playerId)"
/>
</div>
</td>
<td v-if="showRank" class="mono rank">{{ row.rank }}</td> <td v-if="showRank" class="mono rank">{{ row.rank }}</td>
<td v-if="showSeed" class="mono">{{ row.seed }}</td> <td v-if="showSeed" class="mono">{{ row.seed }}</td>
</tr> </tr>
<tr v-if="filteredRows.length === 0">
<td :colspan="columnCount" class="muted center">{{ t('labels.noPlayers') }}</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="mobile-score-cards"> <div class="mobile-score-cards">
<article <article v-for="(row, index) in filteredRows" :key="'mob-' + stage + '-' + row.playerId" class="score-card score-group-card" :style="scoreGroupStyleFor(row)">
v-for="(row, index) in section.rows"
:key="'mob-' + stage + '-' + section.key + '-' + row.playerId"
class="score-card score-group-card"
:style="scoreRowStyleFor(row, section)"
>
<div class="score-card-head"> <div class="score-card-head">
<div class="competitor-cell compact"> <div class="competitor-cell compact">
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" /> <img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
@@ -98,15 +106,14 @@
<span v-if="showSeed">{{ t('table.seed') }}: {{ row.seed }}</span> <span v-if="showSeed">{{ t('table.seed') }}: {{ row.seed }}</span>
</div> </div>
<label class="score-label" :class="{ 'stage-column-highlight': isHighlightedStage(stage, section) }">{{ inputLabel }}</label> <label class="score-label">{{ inputLabel }}</label>
<input <input
class="score-input" class="score-input"
:class="{ 'score-input-over-limit': scoreInputInvalid(stage, row.playerId), 'score-input-stage-highlight': isHighlightedStage(stage, section) }"
type="number" type="number"
inputmode="numeric" inputmode="numeric"
pattern="[0-9]*" pattern="[0-9]*"
min="0" min="0"
max="100" max="9999"
:value="scoreInputValue(stage, row.playerId)" :value="scoreInputValue(stage, row.playerId)"
@focus="onScoreFocus(stage, row.playerId)" @focus="onScoreFocus(stage, row.playerId)"
@input="onScoreInput(stage, row.playerId, $event)" @input="onScoreInput(stage, row.playerId, $event)"
@@ -114,10 +121,18 @@
@keydown.enter.prevent="onScoreCommit(stage, row.playerId)" @keydown.enter.prevent="onScoreCommit(stage, row.playerId)"
/> />
<div v-if="showProofControls" class="proof-actions mobile-proof-actions"> <div class="proof-actions mobile-proof-actions">
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(stage, row.playerId)"> <button class="btn btn-outline btn-xs" @click="openScoreProofUploader(stage, row.playerId)">
{{ hasScoreProof(stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }} {{ hasScoreProof(stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
</button> </button>
<!-- <button
v-if="hasScoreProof(stage, row.playerId)"
class="btn btn-ai btn-xs"
@click="requestScoreAdvice(stage, row.playerId)"
>
{{ t('actions.aiAdvisor') }}
</button> -->
<button v-if="hasScoreProof(stage, row.playerId)" class="btn btn-danger btn-xs" @click="removeScoreProof(stage, row.playerId)"> <button v-if="hasScoreProof(stage, row.playerId)" class="btn btn-danger btn-xs" @click="removeScoreProof(stage, row.playerId)">
{{ t('actions.removeProof') }} {{ t('actions.removeProof') }}
</button> </button>
@@ -130,18 +145,14 @@
/> />
</div> </div>
</article> </article>
<div v-if="filteredRows.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
</div> </div>
</template>
</div>
<div v-if="groupedSections.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed } from 'vue'
import { groupThemeStyleForKey, scoreGroupStyle, scoreValueStyle } from '../../utils/scoreGroupTheme' import { scoreGroupStyle, scoreValueStyle } from '../../utils/scoreGroupTheme'
import { normalizedGroupCode } from '../../utils/groups'
const props = defineProps({ const props = defineProps({
t: { type: Function, required: true }, t: { type: Function, required: true },
@@ -155,14 +166,10 @@ const props = defineProps({
colorMode: { type: String, default: 'group' }, colorMode: { type: String, default: 'group' },
showScoreBeforeInput: { type: Boolean, default: false }, showScoreBeforeInput: { type: Boolean, default: false },
inputLabel: { type: String, required: true }, inputLabel: { type: String, required: true },
sectionByGroup: { type: Boolean, default: false },
tieBreakSectionMode: { type: Boolean, default: false },
showProofControls: { type: Boolean, default: true },
playerImage: { type: Function, required: true }, playerImage: { type: Function, required: true },
displayName: { type: Function, required: true }, displayName: { type: Function, required: true },
secondaryName: { type: Function, required: true }, secondaryName: { type: Function, required: true },
scoreInputValue: { type: Function, required: true }, scoreInputValue: { type: Function, required: true },
scoreInputInvalid: { type: Function, required: true },
onScoreFocus: { type: Function, required: true }, onScoreFocus: { type: Function, required: true },
onScoreInput: { type: Function, required: true }, onScoreInput: { type: Function, required: true },
onScoreCommit: { type: Function, required: true }, onScoreCommit: { type: Function, required: true },
@@ -172,14 +179,10 @@ const props = defineProps({
removeScoreProof: { type: Function, required: true }, removeScoreProof: { type: Function, required: true },
openProofPreview: { type: Function, required: true }, openProofPreview: { type: Function, required: true },
requestScoreAdvice: { type: Function, required: true }, requestScoreAdvice: { type: Function, required: true },
highlightStage: { type: String, default: '' },
highlightGroup: { type: String, default: '' },
}) })
defineEmits(['update:filter']) defineEmits(['update:filter'])
const collapsedSections = ref({})
const filteredRows = computed(() => { const filteredRows = computed(() => {
const query = String(props.filterText || '').trim().toLowerCase() const query = String(props.filterText || '').trim().toLowerCase()
if (!query) return props.rows if (!query) return props.rows
@@ -190,151 +193,19 @@ const filteredRows = computed(() => {
}) })
}) })
const groupedSections = computed(() => { const columnCount = computed(() => {
if (!props.sectionByGroup) { let count = 4
return [ if (props.showGroup) count += 1
{ if (props.showScoreBeforeInput) count += 1
key: 'all', if (props.showRank) count += 1
label: props.t('labels.allPlayers'), if (props.showSeed) count += 1
rows: filteredRows.value, return count
style: null,
},
].filter((section) => section.rows.length > 0)
}
const buckets = new Map()
for (const row of filteredRows.value) {
const key = sectionKeyForRow(row)
if (!buckets.has(key)) {
buckets.set(key, {
key,
label: sectionLabelForKey(key),
rows: [],
})
}
buckets.get(key).rows.push(row)
}
return [...buckets.values()]
.sort((a, b) => sectionSortValue(a.key) - sectionSortValue(b.key) || String(a.key).localeCompare(String(b.key)))
.map((section) => ({
...section,
style: sectionStyleFor(section.key, section.rows[0]),
}))
}) })
watch( function scoreGroupStyleFor(row) {
groupedSections, if (props.colorMode === 'score') {
(sections) => { return scoreValueStyle(row?.score)
const next = {}
for (const section of sections) {
next[section.key] = collapsedSections.value[section.key] || false
} }
collapsedSections.value = next
},
{ immediate: true },
)
function sectionKeyForRow(row) {
const boardGroup = String(row?._boardGroupKey || '').trim()
if (boardGroup) return `B${boardGroup}`
if (props.tieBreakSectionMode) {
const tieGroup = Number.parseInt(String(row?.scoreGroup || ''), 10)
if (Number.isFinite(tieGroup) && tieGroup > 0) return `T${tieGroup}`
}
const finalGroup = Number.parseInt(String(row?.finalGroup || ''), 10)
if (Number.isFinite(finalGroup) && finalGroup > 0) return `F${finalGroup}`
const group = normalizedGroupCode(row?.groupCode || '')
if (group) return `G${group}`
return 'U'
}
function sectionLabelForKey(key) {
if (key === 'U') return `${props.t('labels.group')} ${props.t('labels.unassigned')}`
if (key.startsWith('B')) return `${props.t('labels.group')} ${key.slice(1)}`
if (key.startsWith('T')) return `${props.t('labels.group')} ${key.slice(1)}`
if (key.startsWith('F')) return `${props.t('labels.group')} ${key.slice(1)}`
if (key.startsWith('G')) return `${props.t('labels.group')} ${key.slice(1)}`
return `${props.t('labels.group')} ${key}`
}
function sectionSortValue(key) {
if (key === 'U') return 9999
if (key.startsWith('B')) {
const raw = key.slice(1)
const parsed = Number.parseInt(raw, 10)
if (Number.isFinite(parsed) && parsed > 0) return parsed
const code = String(raw).toUpperCase()
if (code === 'A') return 1
if (code === 'B') return 2
if (code === 'C') return 3
if (code === 'D') return 4
return 5000 + hashToHue(code)
}
if (key.startsWith('T') || key.startsWith('F')) return Number.parseInt(key.slice(1), 10) || 9998
if (key.startsWith('G')) {
const raw = key.slice(1)
const code = String(raw || '').trim().toUpperCase()
if (code === 'A') return 1
if (code === 'B') return 2
if (code === 'C') return 3
if (code === 'D') return 4
return 5000 + hashToHue(code)
}
return 8000 + hashToHue(key)
}
function sectionStyleFor(key, sampleRow) {
if (key.startsWith('B')) return groupThemeStyleForKey(key)
if (props.tieBreakSectionMode) {
return groupThemeStyleForKey(key, { tieBreak: true })
}
return scoreGroupStyle(sampleRow)
}
function scoreRowStyleFor(row, section) {
if (section?.key?.startsWith('B') && section?.style) return section.style
if (props.tieBreakSectionMode && section?.style) return section.style
if (props.colorMode === 'score') return scoreValueStyle(row?.score)
return scoreGroupStyle(row) return scoreGroupStyle(row)
} }
function isCollapsed(sectionKey) {
return Boolean(collapsedSections.value[sectionKey])
}
function toggleSection(sectionKey) {
collapsedSections.value[sectionKey] = !collapsedSections.value[sectionKey]
}
function isHighlightedStage(stageName, section) {
const stageMatch = String(props.highlightStage || '').trim().toLowerCase() === String(stageName || '').trim().toLowerCase()
if (!stageMatch) return false
const wanted = String(props.highlightGroup || '').trim().toUpperCase()
if (!wanted) return true
return sectionGroupKey(section) === wanted
}
function sectionGroupKey(section) {
const key = String(section?.key || '').trim()
if (!key) return ''
if (key === 'U') return 'UNASSIGNED'
if (key.startsWith('B') || key.startsWith('F') || key.startsWith('G') || key.startsWith('T')) {
return String(key.slice(1)).trim().toUpperCase()
}
return key.toUpperCase()
}
function hashToHue(value) {
const input = String(value || 'U')
let hash = 0
for (let i = 0; i < input.length; i += 1) {
hash = (hash * 33 + input.charCodeAt(i)) % 360
}
return (hash + 360) % 360
}
</script> </script>

View File

@@ -14,7 +14,6 @@ export const I18N = {
liveTracker: 'LIVE TRACKER', liveTracker: 'LIVE TRACKER',
mode: 'الوضع', mode: 'الوضع',
language: 'اللغة', language: 'اللغة',
fullscreen: 'ملء الشاشة',
lastSync: 'آخر مزامنة', lastSync: 'آخر مزامنة',
loading: 'جاري تحميل بيانات البطولة...', loading: 'جاري تحميل بيانات البطولة...',
players: 'لاعب', players: 'لاعب',
@@ -24,14 +23,11 @@ export const I18N = {
allPlayers: 'جميع اللاعبين', allPlayers: 'جميع اللاعبين',
top12: 'أفضل 12 متأهل', top12: 'أفضل 12 متأهل',
finalist: 'متأهل', finalist: 'متأهل',
tieBreak: 'كسر تعادل',
notFinalist: 'غير متأهل', notFinalist: 'غير متأهل',
noPlayers: 'لا يوجد لاعبين بعد', noPlayers: 'لا يوجد لاعبين بعد',
noFinalists: 'لا يوجد متأهلون بعد', noFinalists: 'لا يوجد متأهلون بعد',
finalGroup1: 'المجموعة النهائية 1 (المراكز 1-6)', finalGroup1: 'المجموعة النهائية 1 (المراكز 1-6)',
finalGroup2: 'المجموعة النهائية 2 (المراكز 7-12)', finalGroup2: 'المجموعة النهائية 2 (المراكز 7-12)',
finalGroupSeeds1: 'المجموعة النهائية (المراكز 1-6)',
finalGroupSeeds2: 'المجموعة النهائية (المراكز 7-12)',
tieSlots: 'عدد المقاعد المتاحة من كسر التعادل', tieSlots: 'عدد المقاعد المتاحة من كسر التعادل',
waiting: 'بانتظار النتيجة', waiting: 'بانتظار النتيجة',
viewProofInView: 'إظهار صور إثبات النتيجة في وضع العرض', viewProofInView: 'إظهار صور إثبات النتيجة في وضع العرض',
@@ -44,7 +40,6 @@ export const I18N = {
liveViewPrelimOverall: 'الترتيب العام للتمهيدي', liveViewPrelimOverall: 'الترتيب العام للتمهيدي',
liveViewFinalGroups: 'شاشة مجموعات النهائي', liveViewFinalGroups: 'شاشة مجموعات النهائي',
liveViewFinalTie: 'كسر تعادل النهائي', liveViewFinalTie: 'كسر تعادل النهائي',
liveViewFinalOverall: 'الترتيب العام للنهائي',
liveViewPodium: 'منصة التتويج', liveViewPodium: 'منصة التتويج',
showGroupLive: 'إظهار شاشة المجموعات الحية', showGroupLive: 'إظهار شاشة المجموعات الحية',
showPrelimTie: 'إظهار كسر تعادل التمهيدي', showPrelimTie: 'إظهار كسر تعادل التمهيدي',
@@ -61,21 +56,6 @@ export const I18N = {
currentScore: 'النتيجة الحالية', currentScore: 'النتيجة الحالية',
aiSuggestedScore: 'النتيجة المقترحة', aiSuggestedScore: 'النتيجة المقترحة',
confidence: 'مستوى الثقة', confidence: 'مستوى الثقة',
stageControl: 'المرحلة الحالية',
stagePhase: 'الطور',
stageGroup: 'المجموعة',
stageRound: 'الجولة',
currentStage: 'المرحلة الجارية',
lastStage: 'آخر مرحلة',
startedAt: 'بدأت',
endedAt: 'انتهت',
noCurrentStage: 'لا توجد مرحلة جارية',
noLastStage: 'لا توجد مرحلة سابقة',
phasePreliminary: 'التمهيدي',
phasePrelimTie: 'كسر تعادل التمهيدي',
phaseFinal: 'النهائي',
phaseFinalTie: 'كسر تعادل النهائي',
groupAssignment: 'توزيع المجموعات',
}, },
sections: { sections: {
groupsTitle: 'عرض اللاعبين والمجموعات', groupsTitle: 'عرض اللاعبين والمجموعات',
@@ -112,7 +92,6 @@ export const I18N = {
competitor: 'اللاعب', competitor: 'اللاعب',
group: 'المجموعة', group: 'المجموعة',
rank: 'الترتيب', rank: 'الترتيب',
position: 'الموضع',
score: 'النتيجة', score: 'النتيجة',
total: 'المجموع', total: 'المجموع',
round1: 'جولة 1', round1: 'جولة 1',
@@ -141,7 +120,6 @@ export const I18N = {
removeImage: 'حذف الصورة', removeImage: 'حذف الصورة',
delete: 'حذف', delete: 'حذف',
resetScores: 'تصفير نتائج المرحلة', resetScores: 'تصفير نتائج المرحلة',
resetFinalGroupBySeed: 'إعادة مجموعات النهائي حسب التصنيف (1-6 / 7-12)',
resetOnlyScores: 'تصفير النتائج فقط', resetOnlyScores: 'تصفير النتائج فقط',
resetScoresAndProofs: 'تصفير النتائج وحذف الإثبات', resetScoresAndProofs: 'تصفير النتائج وحذف الإثبات',
randomEvenGroups: 'توزيع عشوائي متوازن', randomEvenGroups: 'توزيع عشوائي متوازن',
@@ -161,11 +139,6 @@ export const I18N = {
resetPosition: 'إعادة الضبط', resetPosition: 'إعادة الضبط',
liveModeRotate: 'تدوير تلقائي', liveModeRotate: 'تدوير تلقائي',
liveModeFixed: 'ثابت', liveModeFixed: 'ثابت',
enterFullscreen: 'دخول ملء الشاشة',
exitFullscreen: 'الخروج من ملء الشاشة',
startStage: 'بدء المرحلة',
endStage: 'إنهاء المرحلة',
goToStageScoring: 'الانتقال إلى إدخال نتائج المرحلة',
}, },
auth: { auth: {
username: 'اسم المستخدم', username: 'اسم المستخدم',
@@ -193,14 +166,13 @@ export const I18N = {
confirmDelete: 'هل تريد حذف اللاعب؟', confirmDelete: 'هل تريد حذف اللاعب؟',
confirmReset: 'هل تريد تصفير نتائج هذه المرحلة؟', confirmReset: 'هل تريد تصفير نتائج هذه المرحلة؟',
resetProofPrompt: 'هل تريد أيضًا حذف صور الإثبات لهذه المرحلة؟', resetProofPrompt: 'هل تريد أيضًا حذف صور الإثبات لهذه المرحلة؟',
invalidScore: 'النتيجة يجب أن تكون من 0 إلى 100.', invalidScore: 'النتيجة يجب أن تكون من 0 إلى 9999.',
unauthorized: 'انتهت صلاحية جلسة الإدارة. يرجى تسجيل الدخول مرة أخرى.', unauthorized: 'انتهت صلاحية جلسة الإدارة. يرجى تسجيل الدخول مرة أخرى.',
errorPrefix: 'حدث خطأ', errorPrefix: 'حدث خطأ',
noGroupsConfigured: 'لا توجد مجموعات أساسية مهيأة.', noGroupsConfigured: 'لا توجد مجموعات أساسية مهيأة.',
noNameToConvert: 'لا يوجد اسم للتحويل.', noNameToConvert: 'لا يوجد اسم للتحويل.',
aiAnalyzing: 'جاري تحليل الصورة بالذكاء الاصطناعي...', aiAnalyzing: 'جاري تحليل الصورة بالذكاء الاصطناعي...',
noProofForAi: 'لا توجد صورة إثبات لتحليلها.', noProofForAi: 'لا توجد صورة إثبات لتحليلها.',
noGroupForPhase: 'لا توجد مجموعات متاحة لهذا الطور.',
}, },
}, },
en: { en: {
@@ -218,7 +190,6 @@ export const I18N = {
liveTracker: 'LIVE TRACKER', liveTracker: 'LIVE TRACKER',
mode: 'Mode', mode: 'Mode',
language: 'Language', language: 'Language',
fullscreen: 'Fullscreen',
lastSync: 'Last sync', lastSync: 'Last sync',
loading: 'Loading tournament data...', loading: 'Loading tournament data...',
players: 'Players', players: 'Players',
@@ -228,14 +199,11 @@ export const I18N = {
allPlayers: 'All players', allPlayers: 'All players',
top12: 'Top 12 finalists', top12: 'Top 12 finalists',
finalist: 'Finalist', finalist: 'Finalist',
tieBreak: 'Tie-break',
notFinalist: 'Not finalist', notFinalist: 'Not finalist',
noPlayers: 'No players yet', noPlayers: 'No players yet',
noFinalists: 'No finalists yet', noFinalists: 'No finalists yet',
finalGroup1: 'Final Group 1 (Seeds 1-6)', finalGroup1: 'Final Group 1 (Seeds 1-6)',
finalGroup2: 'Final Group 2 (Seeds 7-12)', finalGroup2: 'Final Group 2 (Seeds 7-12)',
finalGroupSeeds1: 'Final Group Seeds 1-6',
finalGroupSeeds2: 'Final Group Seeds 7-12',
tieSlots: 'Tie-break slots', tieSlots: 'Tie-break slots',
waiting: 'Waiting', waiting: 'Waiting',
viewProofInView: 'Allow proof images in view mode', viewProofInView: 'Allow proof images in view mode',
@@ -248,7 +216,6 @@ export const I18N = {
liveViewPrelimOverall: 'Preliminary overall', liveViewPrelimOverall: 'Preliminary overall',
liveViewFinalGroups: 'Final groups board', liveViewFinalGroups: 'Final groups board',
liveViewFinalTie: 'Final tie-break', liveViewFinalTie: 'Final tie-break',
liveViewFinalOverall: 'Final overall ranking',
liveViewPodium: 'Podium', liveViewPodium: 'Podium',
showGroupLive: 'Show live group board', showGroupLive: 'Show live group board',
showPrelimTie: 'Show preliminary tie-break', showPrelimTie: 'Show preliminary tie-break',
@@ -265,21 +232,6 @@ export const I18N = {
currentScore: 'Current score', currentScore: 'Current score',
aiSuggestedScore: 'AI suggested score', aiSuggestedScore: 'AI suggested score',
confidence: 'Confidence', 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: { sections: {
groupsTitle: 'Players & Groups Overview', groupsTitle: 'Players & Groups Overview',
@@ -316,7 +268,6 @@ export const I18N = {
competitor: 'Player', competitor: 'Player',
group: 'Group', group: 'Group',
rank: 'Rank', rank: 'Rank',
position: 'Position',
score: 'Score', score: 'Score',
total: 'Total', total: 'Total',
round1: 'Round 1', round1: 'Round 1',
@@ -345,7 +296,6 @@ export const I18N = {
removeImage: 'Remove Image', removeImage: 'Remove Image',
delete: 'Delete', delete: 'Delete',
resetScores: 'Reset Stage Scores', resetScores: 'Reset Stage Scores',
resetFinalGroupBySeed: 'Reset Final Groups By Seed (1-6 / 7-12)',
resetOnlyScores: 'Reset scores only', resetOnlyScores: 'Reset scores only',
resetScoresAndProofs: 'Reset scores and proofs', resetScoresAndProofs: 'Reset scores and proofs',
randomEvenGroups: 'Random even grouping', randomEvenGroups: 'Random even grouping',
@@ -365,11 +315,6 @@ export const I18N = {
resetPosition: 'Reset position', resetPosition: 'Reset position',
liveModeRotate: 'Auto rotate', liveModeRotate: 'Auto rotate',
liveModeFixed: 'Fixed', liveModeFixed: 'Fixed',
enterFullscreen: 'Enter Fullscreen',
exitFullscreen: 'Exit Fullscreen',
startStage: 'Start Stage',
endStage: 'End Stage',
goToStageScoring: 'Go To Stage Scoring',
}, },
auth: { auth: {
username: 'Username', username: 'Username',
@@ -397,14 +342,13 @@ export const I18N = {
confirmDelete: 'Delete this player?', confirmDelete: 'Delete this player?',
confirmReset: 'Reset this stage scores?', confirmReset: 'Reset this stage scores?',
resetProofPrompt: 'Also remove all proof images for this stage?', resetProofPrompt: 'Also remove all proof images for this stage?',
invalidScore: 'Score must be between 0 and 100.', invalidScore: 'Score must be between 0 and 9999.',
unauthorized: 'Admin session expired. Please login again.', unauthorized: 'Admin session expired. Please login again.',
errorPrefix: 'Error', errorPrefix: 'Error',
noGroupsConfigured: 'No primary groups configured.', noGroupsConfigured: 'No primary groups configured.',
noNameToConvert: 'No name available to convert.', noNameToConvert: 'No name available to convert.',
aiAnalyzing: 'Analyzing image with AI...', aiAnalyzing: 'Analyzing image with AI...',
noProofForAi: 'No proof image available to analyze.', noProofForAi: 'No proof image available to analyze.',
noGroupForPhase: 'No groups available for this phase.',
}, },
}, },
} }
@@ -448,8 +392,6 @@ export function createInitialState() {
finalRanking: { rows: [], tieBreak: { required: false, resolved: true, slots: 0, playerIds: [] } }, finalRanking: { rows: [], tieBreak: { required: false, resolved: true, slots: 0, playerIds: [] } },
podium: [], podium: [],
}, },
current_stage: null,
last_stage: null,
serverTime: '', serverTime: '',
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +1,15 @@
import { normalizedGroupCode } from './groups' import { normalizedGroupCode } from './groups'
const PRESET = { const PRESET = {
A: { accent: '#0d1931', soft: 'rgba(13, 25, 49, 0.14)', border: 'rgba(13, 25, 49, 0.44)' }, A: { accent: '#2f4d9a', soft: 'rgba(47, 77, 154, 0.10)', border: 'rgba(47, 77, 154, 0.34)' },
B: { accent: '#008ca8', soft: 'rgba(0, 140, 168, 0.14)', border: 'rgba(0, 140, 168, 0.44)' }, B: { accent: '#0d8fa5', soft: 'rgba(13, 143, 165, 0.10)', border: 'rgba(13, 143, 165, 0.34)' },
C: { accent: '#ed2e23', soft: 'rgba(237, 46, 35, 0.14)', border: 'rgba(237, 46, 35, 0.44)' }, C: { accent: '#d54a3f', soft: 'rgba(213, 74, 63, 0.10)', border: 'rgba(213, 74, 63, 0.34)' },
D: { accent: '#1b325f', soft: 'rgba(27, 50, 95, 0.14)', border: 'rgba(27, 50, 95, 0.42)' }, D: { accent: '#c2891f', soft: 'rgba(194, 137, 31, 0.12)', border: 'rgba(194, 137, 31, 0.36)' },
F1: { accent: '#ed2e23', soft: 'rgba(237, 46, 35, 0.14)', border: 'rgba(237, 46, 35, 0.44)' }, F1: { accent: '#5b4fc9', soft: 'rgba(91, 79, 201, 0.11)', border: 'rgba(91, 79, 201, 0.36)' },
F2: { accent: '#008ca8', soft: 'rgba(0, 140, 168, 0.14)', border: 'rgba(0, 140, 168, 0.44)' }, F2: { accent: '#0f9f63', soft: 'rgba(15, 159, 99, 0.11)', border: 'rgba(15, 159, 99, 0.36)' },
U: { accent: '#4c5f86', soft: 'rgba(76, 95, 134, 0.14)', border: 'rgba(76, 95, 134, 0.4)' }, U: { accent: '#7f8ca8', soft: 'rgba(127, 140, 168, 0.11)', border: 'rgba(127, 140, 168, 0.34)' },
} }
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) { export function scoreGroupToken(row) {
const finalGroup = Number(row?.finalGroup || 0) const finalGroup = Number(row?.finalGroup || 0)
if (finalGroup === 1) return 'F1' if (finalGroup === 1) return 'F1'
@@ -39,7 +30,12 @@ export function scoreGroupStyle(row) {
} }
} }
return groupThemeStyleForKey(token) 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)`,
}
} }
export function scoreValueStyle(scoreValue) { export function scoreValueStyle(scoreValue) {
@@ -53,26 +49,6 @@ 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) { function hashToHue(value) {
const input = String(value || 'U') const input = String(value || 'U')
let hash = 0 let hash = 0

View File

@@ -1,137 +0,0 @@
{
"response": {
"protocolVersion": {
"protocol": "HTTP",
"minor": 1,
"major": 1
},
"allHeaders": [],
"statusLine": {
"statusCode": 200,
"protocolVersion": {
"protocol": "HTTP",
"minor": 1,
"major": 1
},
"reasonPhrase": "OK"
},
"locale": "en_GB",
"params": {
"names": []
}
},
"data": [
[
"Input Voltage",
"",
"0",
"0.0",
"V"
],
[
"I/P Fault Voltage",
"",
"0",
"0.0",
"V"
],
[
"Output Voltage",
"",
"0",
"0.0",
"V"
],
[
"Load",
"",
"0",
"0.0",
"%"
],
[
"Input Frequency",
"",
"0",
"0.0",
"Hz"
],
[
"Battery Cell Voltage",
"",
"0",
"0.0",
"V"
],
[
"Temperature",
"",
"0",
"0.0",
"℃"
],
[
"Utility State",
"",
"0",
"0.0",
""
],
[
"Battery Low Voltage",
"",
"0",
"0.0",
""
],
[
"Bypass Mode",
"",
"0",
"0.0",
""
],
[
"Fault State",
"",
"0",
"0.0",
""
],
[
"UPS Type",
"",
"0",
"0.0",
""
],
[
"Testing",
"",
"0",
"0.0",
""
],
[
"Shutdown Active",
"",
"0",
"0.0",
""
],
[
"Buzzer",
"",
"0",
"0.0",
""
],
[
"Communication State",
"2026.04.28 08:05:16",
"0.0",
"Lost",
""
]
]
}

View File

@@ -1,5 +0,0 @@
[
import_deps: [:ecto, :ecto_sql, :phoenix],
subdirectories: ["priv/*/migrations"],
inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}", "priv/*/seeds.exs"]
]

31
phoenix/.gitignore vendored
View File

@@ -1,31 +0,0 @@
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where 3rd-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Temporary files, for example, from tests.
/tmp/
# Ignore package tarball (built via "mix hex.build").
shooting_event_phx-*.tar
# Database files
*.db
*.db-*

View File

@@ -1,111 +0,0 @@
This is a web application written using the Phoenix web framework.
## Project guidelines
- Use `mix precommit` alias when you are done with all changes and fix any pending issues
- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps
### Phoenix v1.8 guidelines
- **Always** begin your LiveView templates with `<Layouts.app flash={@flash} ...>` which wraps all inner content
- The `MyAppWeb.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again
- Anytime you run into errors with no `current_scope` assign:
- You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `<Layouts.app>`
- **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed
- Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module
- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar
- **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will save steps and prevent errors
- If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your
custom classes must fully style the input
<!-- usage-rules-start -->
<!-- phoenix:elixir-start -->
## Elixir guidelines
- Elixir lists **do not support index based access via the access syntax**
**Never do this (invalid)**:
i = 0
mylist = ["blue", "green"]
mylist[i]
Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie:
i = 0
mylist = ["blue", "green"]
Enum.at(mylist, i)
- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc
you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie:
# INVALID: we are rebinding inside the `if` and the result never gets assigned
if connected?(socket) do
socket = assign(socket, :val, val)
end
# VALID: we rebind the result of the `if` to a new variable
socket =
if connected?(socket) do
assign(socket, :val, val)
end
- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors
- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets
- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package)
- Don't use `String.to_atom/1` on user input (memory leak risk)
- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards
- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)`
- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option
## Mix guidelines
- Read the docs and options before using tasks (by using `mix help task_name`)
- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed`
- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason
## Test guidelines
- **Always use `start_supervised!/1`** to start processes in tests as it guarantees cleanup between tests
- **Avoid** `Process.sleep/1` and `Process.alive?/1` in tests
- Instead of sleeping to wait for a process to finish, **always** use `Process.monitor/1` and assert on the DOWN message:
ref = Process.monitor(pid)
assert_receive {:DOWN, ^ref, :process, ^pid, :normal}
- Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages
<!-- phoenix:elixir-end -->
<!-- phoenix:phoenix-start -->
## Phoenix guidelines
- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes.
- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie:
scope "/admin", AppWeb.Admin do
pipe_through :browser
live "/users", UserLive, :index
end
the UserLive route would point to the `AppWeb.Admin.UserLive` module
- `Phoenix.View` no longer is needed or included with Phoenix, don't use it
<!-- phoenix:phoenix-end -->
<!-- phoenix:ecto-start -->
## Ecto Guidelines
- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email`
- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs`
- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string`
- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed
- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields
- Fields which are set programmatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct
- **Always** invoke `mix ecto.gen.migration migration_name_using_underscores` when generating migration files, so the correct timestamp and conventions are applied
<!-- phoenix:ecto-end -->
<!-- usage-rules-end -->

View File

@@ -1,51 +0,0 @@
# Shooting Event Phoenix Backend
This folder contains a Phoenix + SQLite implementation of the existing Go API contract used by the Vue frontend.
## Run (dev)
```bash
cd phoenix
mix deps.get
mix ecto.create
mix ecto.migrate
PORT=8080 mix phx.server
```
Or from project root:
```bash
make phoenix-dev
```
## Build
```bash
make phoenix-build
```
## API compatibility
The Phoenix app exposes the same `/api/*` endpoints currently used by the Vue app, including:
- Public:
- `GET /api/health`
- `GET /api/state`
- `GET /api/events` (SSE)
- Admin:
- `POST /api/admin/login`
- `POST /api/admin/logout`
- `GET /api/admin/state`
- `PUT /api/admin/settings`
- Player CRUD + auto-group
- Score/proof update/delete + reset
- AI score advice endpoint
## Environment
- `PORT` (default: `8080`)
- `DB_PATH` (default: `../data/shooting_phoenix.db`)
- `ADMIN_USER` (default: `datwyler`)
- `ADMIN_PASS` (default: `datwyler`)
- `GEMINI_API_KEY` (default follows existing project fallback)
- `GEMINI_MODEL` (default: `gemini-3.1-flash-lite-preview`)

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More