Compare commits

...

6 Commits

Author SHA1 Message Date
f6df640bd5 update 2026-07-14 22:50:24 +04:00
6f8195cda1 last 2026-05-22 21:08:58 +04:00
2ccfac2d04 final 2026-05-20 10:34:32 +04:00
99d28c2114 update a lot 2026-05-19 13:56:13 +04:00
5863574a78 update 2026-05-18 13:22:11 +04:00
8cda54548f api specs 2026-04-29 00:45:48 +04:00
81 changed files with 8504 additions and 570 deletions

View File

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

182
API_CHANGES.md Normal file
View File

@@ -0,0 +1,182 @@
# API Changes (Not Yet Reflected in `backend/openapi/openapi.json`)
This document lists additive API changes currently implemented in the Go backend.
## Summary
Existing endpoints remain compatible.
Added capabilities:
1. Position board persistence for live/admin ordering.
2. Stage session control (`start`/`end`) for event flow.
3. Stage history retrieval.
4. New state fields (`positionBoards`, `current_stage`, `last_stage`).
## 1) Position Board Endpoint
- Method: `PUT`
- Path: `/api/admin/positions/:board`
- Auth: admin bearer token (`Authorization: Bearer <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,26 +1,18 @@
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend-builder # FROM --platform=$BUILDPLATFORM golang:1.24 AS backend-builder
WORKDIR /app/frontend # WORKDIR /app/backend
COPY frontend/package.json frontend/pnpm-lock.yaml ./ # COPY backend/go.mod backend/go.sum ./
RUN corepack enable && pnpm install --frozen-lockfile # RUN go mod download
COPY frontend/ ./ # COPY backend/ ./
RUN pnpm build # ARG TARGETARCH
# 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 --from=backend-builder /out/shooting-event /app/shooting-event COPY bin/shooting-event-amd64 /app/shooting-event
COPY --from=backend-builder /app/backend/web /app/web COPY frontend/dist /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

44
Dockerfile.phoenix Normal file
View File

@@ -0,0 +1,44 @@
# 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,16 +3,18 @@ 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 IMAGE_NAME ?= shooting-event-2
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 docker-build docker-run clean .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
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 .
@@ -34,12 +36,21 @@ 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
@@ -52,7 +63,20 @@ 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,29 +34,39 @@ 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 Highlights ## API Documentation (OpenAPI)
Public: Interactive docs are now built in:
- `GET /api/health` - Swagger UI: `GET /api/docs`
- `GET /api/state` - OpenAPI JSON: `GET /api/openapi.json`
Admin: ### Testing with Auth in Swagger UI
- `POST /api/admin/login` 1. Open `/api/docs`.
- `POST /api/admin/logout` 2. Use the **Login & Authorize** form at the top:
- `POST /api/admin/players` - Username: `datwyler` (default)
- `PUT /api/admin/players/:id` - Password: `datwyler` (default)
- `DELETE /api/admin/players/:id` 3. The page calls `POST /api/admin/login`, retrieves the token, and auto-authorizes Swagger.
- `PUT /api/admin/scores/:stage/:id` 4. Use **Try it out** on any admin endpoint.
- `POST /api/admin/scores/:stage/:id/advice`
- `POST /api/admin/scores/:stage/reset`
Stages: ### Stage Values
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`
- `prelim_tiebreak`
- `final` - `final`
- `prelim_tiebreak`
- `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, 9999) advised := clampInt(raw.AdvisedScore, 0, 100)
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."

145
backend/api_docs.go Normal file
View File

@@ -0,0 +1,145 @@
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,12 +59,31 @@ 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..9999>,"reason":"<max 18 words>"} {"advisedScore":<int 0..100>,"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,6 +78,84 @@ 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 {
@@ -295,6 +373,77 @@ 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 {
@@ -310,8 +459,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 > 9999 { if req.Score < 0 || req.Score > 100 {
return writeError(c, http.StatusBadRequest, "score must be between 0 and 9999") return writeError(c, http.StatusBadRequest, "score must be between 0 and 100")
} }
res, err := a.db.Exec(` res, err := a.db.Exec(`
@@ -382,7 +531,9 @@ func resolveResetStages(stage string) ([]string, error) {
case "preliminary": case "preliminary":
return append([]string{}, preliminaryRoundStages...), nil return append([]string{}, preliminaryRoundStages...), nil
case "final": case "final":
return append([]string{}, finalRoundStages...), nil stages := append([]string{}, finalRoundStages...)
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

@@ -84,13 +84,30 @@ type FinalGroups struct {
} }
type StateResponse struct { type StateResponse struct {
Competition CompetitionMeta `json:"competition"` Competition CompetitionMeta `json:"competition"`
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"`
Settings AppSettings `json:"settings"` PositionBoards map[string]map[string]PositionSlot `json:"positionBoards"`
Derived DerivedState `json:"derived"` Settings AppSettings `json:"settings"`
ServerTime string `json:"serverTime"` Derived DerivedState `json:"derived"`
CurrentStage *StageSessionState `json:"current_stage"`
LastStage *StageSessionState `json:"last_stage"`
ServerTime string `json:"serverTime"`
}
type StageSessionState struct {
ID int `json:"id"`
Phase string `json:"phase"`
GroupKey string `json:"group"`
Round int `json:"round"`
StartedAt string `json:"startedAt"`
EndedAt string `json:"endedAt,omitempty"`
}
type PositionSlot struct {
GroupKey string `json:"groupKey"`
Position int `json:"position"`
} }
type AppSettings struct { type AppSettings struct {
@@ -170,6 +187,16 @@ 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"`
@@ -177,3 +204,9 @@ 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"`
}

1410
backend/openapi/openapi.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,10 @@ 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)
@@ -18,9 +22,13 @@ 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", "podium": case "group_live", "prelim_tie", "prelim_overall", "final_groups", "final_tie", "final_overall", "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)) != "" {

172
backend/stage_sessions.go Normal file
View File

@@ -0,0 +1,172 @@
package main
import (
"database/sql"
"fmt"
"strconv"
"strings"
"time"
)
const (
stagePhasePreliminary = "preliminary"
stagePhasePrelimTie = "prelim_tiebreak"
stagePhaseFinal = "final"
stagePhaseFinalTie = "final_tiebreak"
)
var allowedStagePhases = map[string]bool{
stagePhasePreliminary: true,
stagePhasePrelimTie: true,
stagePhaseFinal: true,
stagePhaseFinalTie: true,
}
func normalizeStagePhase(raw string) (string, bool) {
phase := strings.ToLower(strings.TrimSpace(raw))
if !allowedStagePhases[phase] {
return "", false
}
return phase, true
}
func normalizeStageGroup(phase string, raw string) (string, bool) {
group := strings.TrimSpace(raw)
switch phase {
case stagePhasePreliminary:
if strings.EqualFold(group, positionBoardUnassigned) {
return positionBoardUnassigned, true
}
group = normalizePreliminaryGroupKey(group)
if group == "" {
return "", false
}
return group, true
case stagePhaseFinal:
parsed, err := strconv.Atoi(group)
if err != nil || (parsed != 1 && parsed != 2) {
return "", false
}
return strconv.Itoa(parsed), true
case stagePhasePrelimTie, stagePhaseFinalTie:
parsed, err := strconv.Atoi(group)
if err != nil || parsed <= 0 {
return "", false
}
return strconv.Itoa(parsed), true
default:
return "", false
}
}
func normalizeStageRound(phase string, round int) int {
switch phase {
case stagePhasePreliminary:
if round < 1 {
return 1
}
if round > 3 {
return 3
}
return round
case stagePhaseFinal:
if round < 1 {
return 1
}
if round > 2 {
return 2
}
return round
default:
return 1
}
}
func normalizeTimestamp(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
if parsed, err := time.Parse(time.RFC3339Nano, trimmed); err == nil {
return parsed.UTC().Format(time.RFC3339)
}
if parsed, err := time.Parse("2006-01-02 15:04:05", trimmed); err == nil {
return parsed.UTC().Format(time.RFC3339)
}
return trimmed
}
func scanStageSession(scanner interface{ Scan(dest ...any) error }) (*StageSessionState, error) {
var item StageSessionState
var ended sql.NullString
if err := scanner.Scan(&item.ID, &item.Phase, &item.GroupKey, &item.Round, &item.StartedAt, &ended); err != nil {
return nil, err
}
item.Phase = strings.ToLower(strings.TrimSpace(item.Phase))
item.GroupKey = strings.TrimSpace(item.GroupKey)
item.StartedAt = normalizeTimestamp(item.StartedAt)
if ended.Valid {
item.EndedAt = normalizeTimestamp(ended.String)
}
return &item, nil
}
func (a *App) readCurrentStage() (*StageSessionState, error) {
row := a.db.QueryRow(`
SELECT id, phase, group_key, round, started_at, ended_at
FROM stage_sessions
WHERE ended_at IS NULL
ORDER BY started_at DESC, id DESC
LIMIT 1
`)
item, err := scanStageSession(row)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("read current stage: %w", err)
}
return item, nil
}
func (a *App) readLastStage() (*StageSessionState, error) {
row := a.db.QueryRow(`
SELECT id, phase, group_key, round, started_at, ended_at
FROM stage_sessions
ORDER BY started_at DESC, id DESC
LIMIT 1
`)
item, err := scanStageSession(row)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("read last stage: %w", err)
}
return item, nil
}
func (a *App) readStageHistory() ([]StageSessionState, error) {
rows, err := a.db.Query(`
SELECT id, phase, group_key, round, started_at, ended_at
FROM stage_sessions
ORDER BY started_at DESC, id DESC
`)
if err != nil {
return nil, fmt.Errorf("query stage history: %w", err)
}
defer rows.Close()
items := make([]StageSessionState, 0)
for rows.Next() {
item, scanErr := scanStageSession(rows)
if scanErr != nil {
return nil, fmt.Errorf("scan stage history: %w", scanErr)
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate stage history: %w", err)
}
return items, nil
}

View File

@@ -8,6 +8,21 @@ import (
"time" "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 {
@@ -38,17 +53,32 @@ 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{
TitleAr: "بطولة دويتوايلر للرماية", TitleAr: "بطولة دويتوايلر للرماية",
TitleEn: "Datwyler Shooting Event", TitleEn: "Datwyler Shooting Event",
}, },
Players: players, Players: players,
Scores: scoreMapToJSON(scoreMap), Scores: scoreMapToJSON(scoreMap),
Settings: settings, PositionBoards: positionBoardMapToJSON(positionBoards),
Derived: derived, Settings: settings,
ServerTime: time.Now().UTC().Format(time.RFC3339), Derived: derived,
CurrentStage: currentStage,
LastStage: lastStage,
ServerTime: time.Now().UTC().Format(time.RFC3339),
} }
if includeScoreProofs { if includeScoreProofs {
response.ScoreProofs = scoreProofMapToJSON(scoreProofs) response.ScoreProofs = scoreProofMapToJSON(scoreProofs)
@@ -133,6 +163,210 @@ 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

BIN
frontend/public/arture.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="app-shell" :class="'lang-' + language"> <div class="app-shell" :class="['lang-' + language, 'mode-' + mode]">
<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,9 +8,11 @@
: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">
@@ -69,18 +71,20 @@
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"
@@ -110,6 +114,12 @@
: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"
@@ -129,10 +139,12 @@
: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"
@@ -146,6 +158,10 @@
@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"
@@ -162,6 +178,8 @@
@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>
@@ -216,12 +234,14 @@ 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))
@@ -243,6 +263,11 @@ 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')
@@ -294,6 +319,14 @@ 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 || {}
@@ -316,6 +349,10 @@ 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') },
@@ -405,7 +442,8 @@ 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 []
return playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code) const rows = playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
return sortRowsByPreliminaryPosition(rows)
}) })
const liveMonitorGroupRotationCodes = computed(() => activeGroupCodes.value) const liveMonitorGroupRotationCodes = computed(() => activeGroupCodes.value)
@@ -420,7 +458,8 @@ 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 []
return playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code) const rows = playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
return sortRowsByPreliminaryPosition(rows)
}) })
const liveMonitorFinalGroup = computed(() => { const liveMonitorFinalGroup = computed(() => {
if (liveSettings.value.finalDisplayMode === 'fixed') { if (liveSettings.value.finalDisplayMode === 'fixed') {
@@ -428,17 +467,32 @@ const liveMonitorFinalGroup = computed(() => {
} }
return liveMonitorFinalIndex.value % 2 === 0 ? 1 : 2 return liveMonitorFinalIndex.value % 2 === 0 ? 1 : 2
}) })
const liveMonitorFinalRows = computed(() => (liveMonitorFinalGroup.value === 2 ? finalGroup2.value : finalGroup1.value)) const finalBoardGroupedRows = computed(() => {
const sorted = sortRowsByBoardPosition('final', adminFinalRows.value)
const grouped = {}
for (const row of sorted) {
const key = positionSlotFor('final', row.playerId)?.groupKey || String(row.finalGroup || 1)
if (!grouped[key]) grouped[key] = []
grouped[key].push(row)
}
return grouped
})
const liveMonitorFinalRows = computed(() => {
const key = String(liveMonitorFinalGroup.value === 2 ? 2 : 1)
return finalBoardGroupedRows.value[key] || []
})
const podiumOrdered = computed(() => [podiumRows.value[1] || null, podiumRows.value[0] || null, podiumRows.value[2] || null]) const 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 || [])
return preliminaryRows.value.filter((row) => ids.has(row.playerId)) const rows = preliminaryRows.value.filter((row) => ids.has(row.playerId))
return sortRowsByBoardPosition('prelim_tiebreak', rows)
}) })
const finalTieRows = computed(() => { const finalTieRows = computed(() => {
const ids = new Set(finalTie.value.playerIds || []) const ids = new Set(finalTie.value.playerIds || [])
return finalRows.value.filter((row) => ids.has(row.playerId)) const rows = finalRows.value.filter((row) => ids.has(row.playerId))
return sortRowsByBoardPosition('final_tiebreak', rows)
}) })
const playerByID = computed(() => { const playerByID = computed(() => {
@@ -479,8 +533,7 @@ const adminPreliminaryRows = computed(() => {
rank: derived?.rank ?? 0, rank: derived?.rank ?? 0,
} }
}) })
rows.sort(compareRowsByGroupThenId) return sortRowsByBoardPosition('preliminary', rows)
return rows
}) })
const adminPrelimTieRows = computed(() => { const adminPrelimTieRows = computed(() => {
@@ -500,8 +553,7 @@ const adminPrelimTieRows = computed(() => {
} }
}) })
.filter(Boolean) .filter(Boolean)
rows.sort(compareRowsByScoreGroupThenId) return sortRowsByBoardPosition('prelim_tiebreak', rows)
return rows
}) })
const adminFinalRows = computed(() => { const adminFinalRows = computed(() => {
@@ -521,17 +573,7 @@ const adminFinalRows = computed(() => {
} }
}) })
rows.sort((a, b) => { return sortRowsByBoardPosition('final', rows)
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(() => {
@@ -554,8 +596,29 @@ const adminFinalTieRows = computed(() => {
}) })
.filter(Boolean) .filter(Boolean)
rows.sort(compareRowsByScoreGroupThenId) return sortRowsByBoardPosition('final_tiebreak', rows)
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) => {
@@ -639,7 +702,20 @@ 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()
@@ -647,6 +723,10 @@ 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()
@@ -662,9 +742,60 @@ 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', 'podium']) const allowed = new Set(['group_live', 'prelim_tie', 'prelim_overall', 'final_groups', 'final_tie', 'final_overall', 'podium'])
return allowed.has(normalized) ? normalized : 'group_live' return allowed.has(normalized) ? normalized : 'group_live'
} }
@@ -724,6 +855,116 @@ 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
@@ -765,6 +1006,55 @@ 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')
@@ -926,6 +1216,139 @@ 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')
@@ -1209,7 +1632,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 = Number(scoreAdviceModal.advice.advisedScore || 0) const score = clampScoreValue(scoreAdviceModal.advice.advisedScore)
await updateScoreValue(stage, playerId, score) await updateScoreValue(stage, playerId, score)
scoreAdviceModal.currentScore = score scoreAdviceModal.currentScore = score
closeScoreAdviceModal() closeScoreAdviceModal()
@@ -1287,26 +1710,48 @@ 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 } scoreDrafts[key] = { value: String(scoreFor(stage, playerId)), committing: false, invalid: 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 cleaned = raw.replace(/[^\d]/g, '').slice(0, 4) const digits = raw.replace(/[^\d]/g, '').slice(0, 3)
const current = scoreDrafts[key] || { value: String(scoreFor(stage, playerId)), committing: false } const parsed = parseDraftScoreValue(digits)
scoreDrafts[key] = { ...current, value: cleaned } const invalid = digits !== '' && (parsed < 0 || parsed > 100)
if (event?.target && event.target.value !== cleaned) event.target.value = cleaned const current = scoreDrafts[key] || { value: String(scoreFor(stage, playerId)), committing: false, invalid: false }
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) {
@@ -1314,11 +1759,11 @@ async function onScoreCommit(stage, playerId) {
const draft = scoreDrafts[key] const draft = scoreDrafts[key]
if (!draft || draft.committing) return if (!draft || draft.committing) return
let score = Number(draft.value === '' ? 0 : draft.value) const score = parseDraftScoreValue(draft.value)
if (!Number.isFinite(score)) score = 0 const invalid = score < 0 || score > 100
score = Math.trunc(score)
if (score < 0 || score > 9999) { if (invalid) {
scoreDrafts[key] = { ...draft, invalid: true }
showToast(t('messages.invalidScore'), 'error') showToast(t('messages.invalidScore'), 'error')
return return
} }
@@ -1328,12 +1773,12 @@ async function onScoreCommit(stage, playerId) {
return return
} }
scoreDrafts[key] = { ...draft, committing: true } scoreDrafts[key] = { ...draft, invalid: false, 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 > 9999) { if (score < 0 || score > 100) {
showToast(t('messages.invalidScore'), 'error') showToast(t('messages.invalidScore'), 'error')
return return
} }

View File

@@ -59,7 +59,20 @@
<h2>{{ t('liveMode') }}</h2> <h2>{{ t('liveMode') }}</h2>
<p>{{ t('labels.liveModeVisibility') }}</p> <p>{{ t('labels.liveModeVisibility') }}</p>
</div> </div>
<LiveSettingsCard :t="t" :live-settings="liveSettings" :assignable-groups="assignableGroups" @update-live-setting="$emit('update-live-setting', $event)" /> <LiveSettingsCard
: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">
@@ -81,6 +94,18 @@
/> />
</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"
@@ -92,12 +117,17 @@
: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"
@@ -105,6 +135,7 @@
: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>
@@ -127,18 +158,33 @@
<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"
@@ -148,6 +194,9 @@
: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>
@@ -161,6 +210,7 @@
<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">
@@ -172,6 +222,18 @@
/> />
</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"
@@ -182,12 +244,17 @@
: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"
@@ -195,6 +262,7 @@
: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>
@@ -207,10 +275,22 @@
<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>
<div class="panel-actions">
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'final_tiebreak')">{{ t('actions.resetScores') }}</button>
</div>
<template v-if="finalTie.required"> <template v-if="finalTie.required">
<div class="panel-actions"> <PositionBoardEditor
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'final_tiebreak')">{{ t('actions.resetScores') }}</button> :t="t"
</div> 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"
@@ -218,12 +298,15 @@
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"
@@ -233,6 +316,9 @@
: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>
@@ -283,6 +369,7 @@ 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 },
@@ -290,6 +377,12 @@ 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 },
@@ -309,10 +402,12 @@ 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 },
@@ -329,6 +424,10 @@ 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',
@@ -345,6 +444,8 @@ 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 srcset="/logo.svg" type="image/svg+xml" /> <source v-if="mode !== 'live'" srcset="/logo.svg" type="image/svg+xml" />
<img class="masthead-logo" src="/logo.png" :alt="competitionTitle" /> <img class="masthead-logo" :src="mode === 'live' ? '/logo_white.png' : '/logo.png'" :alt="competitionTitle" />
</picture> </picture>
<p class="masthead-subtitle">{{ t('subtitle') }}</p> <p class="masthead-subtitle">{{ t('subtitle') }}</p>
</div> </div>
@@ -16,6 +16,35 @@
<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">
@@ -23,7 +52,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>
@@ -57,9 +86,10 @@ 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']) const emit = defineEmits(['change-mode', 'change-language', 'toggle-fullscreen'])
const controlsOpen = ref(false) const controlsOpen = ref(false)
const controlsRoot = ref(null) const controlsRoot = ref(null)
@@ -71,6 +101,7 @@ 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')
@@ -91,6 +122,10 @@ 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,14 +1,10 @@
<template> <template>
<section class="live-mode-shell"> <div class="live-mode-root">
<div class="live-mode-overlay"> <section class="live-mode-shell">
<header class="live-mode-header"> <div class="live-mode-overlay">
<h2>{{ t('sections.liveModeTitle') }}</h2> <!-- <div class="live-screen-head live-head-selection">
<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">
@@ -30,7 +26,7 @@
<table class="score-table live-score-table"> <table class="score-table live-score-table">
<thead> <thead>
<tr> <tr>
<th>{{ t('table.rank') }}</th> <th>{{ t('table.position') }}</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>
@@ -39,14 +35,13 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="entry in rankedLiveGroupMembers" :key="'live-group-' + entry.player.id"> <tr v-for="(entry, index) in rankedLiveGroupMembers" :key="'live-group-' + entry.player.id">
<td class="mono rank">{{ entry.rank }}</td> <td class="mono rank">{{ index + 1 }}</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>
@@ -68,41 +63,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="listPageCount > 1" class="live-chip">{{ currentListPageDisplay }}</span> <span v-if="tieGroupCountForActiveView > 1" class="live-chip">{{ currentTieGroupPageDisplay }}</span>
</div> </div>
</div> </div>
<Transition name="live-swap" mode="out-in"> <Transition name="live-swap" mode="out-in">
<div :key="'prelim-tie-page-' + listPageIndex" class="live-swap-block"> <article v-if="activePrelimTieGroup" :key="'live-pre-tie-group-' + activePrelimTieGroup.groupKey" class="live-tie-group-card">
<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>#</th> <th>{{ t('table.position') }}</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="(row, index) in pagedPrelimTieRows" :key="'live-pre-tie-' + row.playerId"> <tr v-for="entry in activePrelimTieGroup.rows" :key="'live-pre-tie-' + activePrelimTieGroup.groupKey + '-' + entry.row.playerId">
<td class="mono">{{ listPageStart + index + 1 }}</td> <td class="mono">{{ entry.position }}</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(entry.row)" :alt="entry.row.nameAr" class="competitor-image" />
<div> <div>
<p class="name-main">{{ displayName(row) }}</p> <p class="name-main">{{ displayName(entry.row) }}</p>
<p class="name-sub">{{ secondaryName(row) }}</p>
</div> </div>
</div> </div>
</td> </td>
<td class="mono strong">{{ row.tieBreak }}</td> <td class="mono strong">{{ entry.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>
</div> </article>
</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">
@@ -130,22 +125,25 @@
<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">{{ row.rank }}</td> <td class="mono rank">
<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="preliminaryRows.length === 0"><td colspan="4" class="muted center">{{ t('labels.noPlayers') }}</td></tr> <tr v-if="preliminaryRowsForLive.length === 0"><td colspan="4" class="muted center">{{ t('labels.noPlayers') }}</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -164,15 +162,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">{{ t('tabs.final') }}</span> <span class="live-focus-label">{{ currentFinalSeedLabel }}</span>
<strong class="live-focus-title">{{ currentFinalGroupLabel }}</strong> <strong class="live-focus-title">{{ currentFinalGroupNumber }}</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.rank') }}</th> <th>{{ t('table.position') }}</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>
@@ -180,14 +178,13 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="entry in rankedLiveFinalRows" :key="'live-final-group-' + entry.row.playerId"> <tr v-for="(entry, index) in rankedLiveFinalRows" :key="'live-final-group-' + entry.row.playerId">
<td class="mono rank">{{ entry.rank }}</td> <td class="mono rank">{{ 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(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>
@@ -208,36 +205,82 @@
<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="listPageCount > 1" class="live-chip">{{ currentListPageDisplay }}</span> <span v-if="tieGroupCountForActiveView > 1" class="live-chip">{{ currentTieGroupPageDisplay }}</span>
</div> </div>
</div> </div>
<Transition name="live-swap" mode="out-in"> <Transition name="live-swap" mode="out-in">
<div :key="'final-tie-page-' + listPageIndex" class="live-swap-block"> <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"> <div class="table-wrap">
<table class="score-table"> <table class="score-table">
<thead> <thead>
<tr> <tr>
<th>#</th> <th>{{ t('table.position') }}</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="(row, index) in pagedFinalTieRows" :key="'live-final-tie-' + row.playerId"> <tr v-for="entry in activeFinalTieGroup.rows" :key="'live-final-tie-' + activeFinalTieGroup.groupKey + '-' + entry.row.playerId">
<td class="mono">{{ listPageStart + index + 1 }}</td> <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>
</div>
</div>
<Transition name="live-swap" mode="out-in">
<div :key="'final-overall-page-' + listPageIndex" class="live-swap-block">
<div class="table-wrap">
<table class="score-table">
<thead>
<tr>
<th>{{ t('table.rank') }}</th>
<th>{{ t('table.competitor') }}</th>
<th>{{ t('table.score') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in pagedFinalOverallRows" :key="'live-final-overall-' + row.playerId">
<td class="mono rank">
<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.tieBreak }}</td> <td class="mono strong">{{ row.score }}</td>
</tr> </tr>
<tr v-if="finalTieRows.length === 0"><td colspan="3" class="muted center">{{ t('messages.noFinalTie') }}</td></tr> <tr v-if="finalOverallRows.length === 0"><td colspan="4" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -256,7 +299,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" :class="{ empty: !podiumHasResult(podiumOrdered[0]) }">{{ podiumName(podiumOrdered[0]) }}</h3> <h3 class="podium-name" dir="auto" :title="podiumName(podiumOrdered[0])" :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>
@@ -268,7 +311,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" :class="{ empty: !podiumHasResult(podiumOrdered[1]) }">{{ podiumName(podiumOrdered[1]) }}</h3> <h3 class="podium-name" dir="auto" :title="podiumName(podiumOrdered[1])" :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>
@@ -280,7 +323,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" :class="{ empty: !podiumHasResult(podiumOrdered[2]) }">{{ podiumName(podiumOrdered[2]) }}</h3> <h3 class="podium-name" dir="auto" :title="podiumName(podiumOrdered[2])" :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>
@@ -289,8 +332,25 @@
</div> </div>
</article> </article>
</div> </div>
</div> </div>
</section>
<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>
</div>
</template> </template>
<script setup> <script setup>
@@ -299,18 +359,20 @@ 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 },
@@ -322,9 +384,14 @@ 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)
@@ -340,10 +407,11 @@ 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_tie') return props.prelimTieRows || [] if (activeView.value === 'prelim_overall') return preliminaryRowsForLive.value
if (activeView.value === 'prelim_overall') return props.preliminaryRows || [] if (activeView.value === 'final_overall') return props.finalOverallRows || []
if (activeView.value === 'final_tie') return props.finalTieRows || []
return [] return []
}) })
@@ -353,30 +421,37 @@ 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 pagedPrelimTieRows = computed(() => paginateRows(props.prelimTieRows || [])) const pagedPreliminaryRows = computed(() => paginateRows(preliminaryRowsForLive.value))
const pagedPreliminaryRows = computed(() => paginateRows(props.preliminaryRows || [])) const pagedFinalOverallRows = computed(() => paginateRows(props.finalOverallRows || []))
const pagedFinalTieRows = computed(() => paginateRows(props.finalTieRows || [])) const groupedPrelimTieRows = computed(() => groupTieRowsByPositionBoard('prelim_tiebreak', props.prelimTieRows || []))
const groupedFinalTieRows = computed(() => groupTieRowsByPositionBoard('final_tiebreak', props.finalTieRows || []))
const tieGroupCountForActiveView = computed(() => {
if (activeView.value === 'prelim_tie') return groupedPrelimTieRows.value.length
if (activeView.value === 'final_tie') return groupedFinalTieRows.value.length
return 0
})
const currentTieGroupPageDisplay = computed(() => {
const total = tieGroupCountForActiveView.value
if (total <= 0) return '0 / 0'
return `${(tieGroupIndex.value % total) + 1} / ${total}`
})
const activePrelimTieGroup = computed(() => {
const groups = groupedPrelimTieRows.value
if (groups.length === 0) return null
return groups[tieGroupIndex.value % groups.length]
})
const activeFinalTieGroup = computed(() => {
const groups = groupedFinalTieRows.value
if (groups.length === 0) return null
return groups[tieGroupIndex.value % groups.length]
})
const rankedLiveGroupMembers = computed(() => { const rankedLiveGroupMembers = computed(() => {
const rows = (props.liveGroupMembers || []).map((player) => ({ return (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(() => {
@@ -386,15 +461,17 @@ 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 currentFinalGroupLabel = computed(() => { const currentFinalSeedLabel = computed(() => {
if (props.liveFinalGroup === 2) return props.t('labels.finalGroup2') if (props.liveFinalGroup === 2) return props.t('labels.finalGroupSeeds2')
return props.t('labels.finalGroup1') return props.t('labels.finalGroupSeeds1')
}) })
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'
@@ -404,44 +481,48 @@ const groupClassKey = computed(() => {
return 'u' return 'u'
}) })
const rankedLiveFinalRows = computed(() => { const rankedLiveFinalRows = computed(() => {
const rows = (props.liveFinalRows || []).map((row) => ({ return (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.finalTieRows.length}`, `${activeView.value}|${rotationIntervalSec.value}|${rotationPlayerCount.value}|${props.prelimTieRows.length}|${props.preliminaryRows.length}|${props.finalOverallRows.length}|${props.finalTieRows.length}`,
() => { () => {
restartListRotation() restartListRotation()
restartTieRotation()
}, },
{ immediate: true }, { immediate: true },
) )
onBeforeUnmount(() => { onBeforeUnmount(() => {
stopListRotation() stopListRotation()
stopTieRotation()
}) })
function paginateRows(rows) { function paginateRows(rows) {
@@ -449,11 +530,36 @@ 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_tie' || activeView.value === 'prelim_overall' || activeView.value === 'final_tie' const autoRotateView = activeView.value === 'prelim_overall' || activeView.value === 'final_overall'
if (!autoRotateView) return if (!autoRotateView) return
if (listPageCount.value <= 1) return if (listPageCount.value <= 1) return
@@ -472,4 +578,62 @@ 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,10 +3,9 @@
<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,6 +12,7 @@
<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>
@@ -77,19 +78,126 @@
@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']) const emit = defineEmits(['update-live-setting', 'update-stage-control', 'start-stage', 'end-stage', 'navigate-current-stage'])
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,24 +9,102 @@
/> />
</div> </div>
<div class="table-wrap desktop-score-table"> <div
<table class="score-table"> v-for="section in groupedSections"
<thead> :key="'multi-section-' + section.key"
<tr> class="score-section-block"
<th>#</th> :class="{ 'score-section-overflow': isOverflow(section) }"
<th>{{ t('table.competitor') }}</th> :style="section.style"
<th v-if="showGroup">{{ t('table.group') }}</th> >
<th v-if="showRank">{{ t('table.rank') }}</th> <button class="score-section-toggle" type="button" @click="toggleSection(section.key)">
<th v-if="showSeed">{{ t('table.seed') }}</th> <span class="score-section-title">{{ section.label }}</span>
<th v-for="round in roundStages" :key="'head-' + round.stage">{{ round.label }}</th> <span class="score-section-meta">{{ section.rows.length }} {{ isCollapsed(section.key) ? '+' : '' }}</span>
<th>{{ t('table.total') }}</th> </button>
<th>{{ t('table.verification') }}</th> <div v-if="isOverflow(section)" class="score-section-alert">{{ section.label }} > {{ overflowLimit }}</div>
</tr>
</thead> <template v-if="!isCollapsed(section.key)">
<tbody> <div class="table-wrap desktop-score-table">
<tr v-for="(row, index) in filteredRows" :key="'multi-row-' + row.playerId" class="score-group-row" :style="scoreGroupStyleFor(row)"> <table class="score-table">
<td class="mono">{{ index + 1 }}</td> <thead>
<td> <tr>
<th>#</th>
<th>{{ t('table.competitor') }}</th>
<th v-if="showGroup">{{ t('table.group') }}</th>
<th v-if="showRank">{{ t('table.rank') }}</th>
<th v-if="showSeed">{{ t('table.seed') }}</th>
<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 v-if="showProofControls">{{ t('table.verification') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in section.rows" :key="'multi-row-' + section.key + '-' + row.playerId" class="score-group-row" :style="scoreGroupStyleFor(row, section)">
<td class="mono">{{ index + 1 }}</td>
<td>
<div class="competitor-cell compact">
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(row) }}</p>
<p class="name-sub">{{ secondaryName(row) }}</p>
</div>
</div>
</td>
<td v-if="showGroup" class="mono">{{ groupCell(row) }}</td>
<td v-if="showRank" class="mono rank">{{ row.rank }}</td>
<td v-if="showSeed" class="mono">{{ row.seed }}</td>
<td
v-for="round in roundStages"
:key="'cell-' + round.stage + '-' + row.playerId"
class="multi-round-cell"
:class="{ 'stage-column-highlight': isHighlightedStage(round.stage, section) }"
>
<input
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"
inputmode="numeric"
pattern="[0-9]*"
min="0"
max="100"
:value="scoreInputValue(round.stage, row.playerId)"
@focus="onScoreFocus(round.stage, row.playerId)"
@input="onScoreInput(round.stage, row.playerId, $event)"
@blur="onScoreCommit(round.stage, row.playerId)"
@keydown.enter.prevent="onScoreCommit(round.stage, row.playerId)"
/>
</td>
<td class="mono strong">{{ totalScore(row.playerId) }}</td>
<td v-if="showProofControls" class="verify-round-cell">
<div class="verify-round-list">
<div v-for="round in roundStages" :key="'verify-' + round.stage + '-' + row.playerId" class="verify-round-item">
<span class="verify-round-label">{{ round.label }}</span>
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(round.stage, row.playerId)">
{{ hasScoreProof(round.stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
</button>
<img
v-if="hasScoreProof(round.stage, row.playerId)"
class="proof-thumb"
:src="scoreProofFor(round.stage, row.playerId)"
:alt="t('table.verification')"
@click="openProofPreview(round.stage, row.playerId)"
/>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<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)">
<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" />
<div> <div>
@@ -34,29 +112,49 @@
<p class="name-sub">{{ secondaryName(row) }}</p> <p class="name-sub">{{ secondaryName(row) }}</p>
</div> </div>
</div> </div>
</td> <div class="mono">#{{ index + 1 }}</div>
<td v-if="showGroup" class="mono">{{ groupCell(row) }}</td> </div>
<td v-if="showRank" class="mono rank">{{ row.rank }}</td>
<td v-if="showSeed" class="mono">{{ row.seed }}</td> <div class="score-card-meta">
<td v-for="round in roundStages" :key="'cell-' + round.stage + '-' + row.playerId" class="multi-round-cell"> <span v-if="showGroup">{{ t('table.group') }}: {{ groupCell(row) }}</span>
<input <span v-if="showRank">{{ t('table.rank') }}: {{ row.rank }}</span>
class="score-input score-input-compact" <span v-if="showSeed">{{ t('table.seed') }}: {{ row.seed }}</span>
type="number" </div>
inputmode="numeric"
pattern="[0-9]*" <div class="mobile-round-grid">
min="0" <label
max="9999" v-for="round in roundStages"
:value="scoreInputValue(round.stage, row.playerId)" :key="'mobile-round-' + round.stage + '-' + row.playerId"
@focus="onScoreFocus(round.stage, row.playerId)" class="mobile-round-item"
@input="onScoreInput(round.stage, row.playerId, $event)" :class="{ 'stage-column-highlight': isHighlightedStage(round.stage, section) }"
@blur="onScoreCommit(round.stage, row.playerId)" >
@keydown.enter.prevent="onScoreCommit(round.stage, row.playerId)" <span class="score-label">{{ round.label }}</span>
/> <input
</td> class="score-input"
<td class="mono strong">{{ totalScore(row.playerId) }}</td> :class="{ 'score-input-over-limit': scoreInputInvalid(round.stage, row.playerId), 'score-input-stage-highlight': isHighlightedStage(round.stage, section) }"
<td class="verify-round-cell"> type="number"
<div class="verify-round-list"> inputmode="numeric"
<div v-for="round in roundStages" :key="'verify-' + round.stage + '-' + row.playerId" class="verify-round-item"> pattern="[0-9]*"
min="0"
max="100"
:value="scoreInputValue(round.stage, row.playerId)"
@focus="onScoreFocus(round.stage, row.playerId)"
@input="onScoreInput(round.stage, row.playerId, $event)"
@blur="onScoreCommit(round.stage, row.playerId)"
@keydown.enter.prevent="onScoreCommit(round.stage, row.playerId)"
/>
</label>
</div>
<div class="mobile-total-line">
<span class="muted">{{ t('table.total') }}</span>
<strong class="mono">{{ totalScore(row.playerId) }}</strong>
</div>
<div v-if="showProofControls" class="mobile-verify-block">
<p class="mobile-verify-title">{{ t('table.verification') }}</p>
<div class="verify-round-list mobile">
<div v-for="round in roundStages" :key="'mobile-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>
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(round.stage, row.playerId)"> <button class="btn btn-outline btn-xs" @click="openScoreProofUploader(round.stage, row.playerId)">
{{ hasScoreProof(round.stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }} {{ hasScoreProof(round.stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
@@ -70,85 +168,20 @@
/> />
</div> </div>
</div> </div>
</td> </div>
</tr> </article>
<tr v-if="filteredRows.length === 0"> </div>
<td :colspan="columnCount" class="muted center">{{ t('labels.noPlayers') }}</td> </template>
</tr>
</tbody>
</table>
</div> </div>
<div class="mobile-score-cards"> <div v-if="groupedSections.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
<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="competitor-cell compact">
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(row) }}</p>
<p class="name-sub">{{ secondaryName(row) }}</p>
</div>
</div>
<div class="mono">#{{ index + 1 }}</div>
</div>
<div class="score-card-meta">
<span v-if="showGroup">{{ t('table.group') }}: {{ groupCell(row) }}</span>
<span v-if="showRank">{{ t('table.rank') }}: {{ row.rank }}</span>
<span v-if="showSeed">{{ t('table.seed') }}: {{ row.seed }}</span>
</div>
<div class="mobile-round-grid">
<label v-for="round in roundStages" :key="'mobile-round-' + round.stage + '-' + row.playerId" class="mobile-round-item">
<span class="score-label">{{ round.label }}</span>
<input
class="score-input"
type="number"
inputmode="numeric"
pattern="[0-9]*"
min="0"
max="9999"
:value="scoreInputValue(round.stage, row.playerId)"
@focus="onScoreFocus(round.stage, row.playerId)"
@input="onScoreInput(round.stage, row.playerId, $event)"
@blur="onScoreCommit(round.stage, row.playerId)"
@keydown.enter.prevent="onScoreCommit(round.stage, row.playerId)"
/>
</label>
</div>
<div class="mobile-total-line">
<span class="muted">{{ t('table.total') }}</span>
<strong class="mono">{{ totalScore(row.playerId) }}</strong>
</div>
<div class="mobile-verify-block">
<p class="mobile-verify-title">{{ t('table.verification') }}</p>
<div class="verify-round-list mobile">
<div v-for="round in roundStages" :key="'mobile-verify-' + round.stage + '-' + row.playerId" class="verify-round-item">
<span class="verify-round-label">{{ round.label }}</span>
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(round.stage, row.playerId)">
{{ hasScoreProof(round.stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
</button>
<img
v-if="hasScoreProof(round.stage, row.playerId)"
class="proof-thumb"
:src="scoreProofFor(round.stage, row.playerId)"
:alt="t('table.verification')"
@click="openProofPreview(round.stage, row.playerId)"
/>
</div>
</div>
</div>
</article>
<div v-if="filteredRows.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed, ref, watch } from 'vue'
import { scoreGroupStyle } from '../../utils/scoreGroupTheme' import { groupThemeStyleForKey, 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 },
@@ -160,10 +193,14 @@ 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 },
@@ -171,10 +208,14 @@ 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
@@ -185,22 +226,144 @@ const filteredRows = computed(() => {
}) })
}) })
const columnCount = computed(() => { const groupedSections = computed(() => {
let count = 4 + props.roundStages.length if (!props.sectionByGroup) {
if (props.showGroup) count += 1 return [
if (props.showRank) count += 1 {
if (props.showSeed) count += 1 key: 'all',
return count label: props.t('labels.allPlayers'),
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 scoreGroupStyleFor(row) { function sectionStyleFor(key, sampleRow) {
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,10 +83,15 @@
<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>
<span class="pm-count-badge mono">{{ group.players.length }}</span> <div class="group-card-head-actions">
<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"> <div class="group-player-list" v-if="group.players.length > 0 && !isGroupCollapsed(group.code)">
<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">
@@ -131,14 +136,14 @@
</div> </div>
</div> </div>
<div v-else class="empty-state">{{ t('labels.noPlayers') }}</div> <div v-else-if="group.players.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
</article> </article>
</div> </div>
</section> </section>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed, ref, watch } from 'vue'
const props = defineProps({ const props = defineProps({
t: { type: Function, required: true }, t: { type: Function, required: true },
@@ -227,6 +232,29 @@ 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

@@ -0,0 +1,249 @@
<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,24 +9,78 @@
/> />
</div> </div>
<div class="table-wrap desktop-score-table"> <div v-for="section in groupedSections" :key="'section-' + stage + '-' + section.key" class="score-section-block" :class="{ 'tie-break': tieBreakSectionMode }" :style="section.style">
<table class="score-table"> <button class="score-section-toggle" type="button" @click="toggleSection(section.key)">
<thead> <span class="score-section-title">{{ section.label }}</span>
<tr> <span class="score-section-meta">{{ section.rows.length }} {{ isCollapsed(section.key) ? '+' : '' }}</span>
<th>#</th> </button>
<th>{{ t('table.competitor') }}</th>
<th v-if="showGroup">{{ t('table.group') }}</th> <template v-if="!isCollapsed(section.key)">
<th v-if="showScoreBeforeInput">{{ t('table.score') }}</th> <div class="table-wrap desktop-score-table">
<th>{{ inputLabel }}</th> <table class="score-table">
<th>{{ t('table.verification') }}</th> <thead>
<th v-if="showRank">{{ t('table.rank') }}</th> <tr>
<th v-if="showSeed">{{ t('table.seed') }}</th> <th>#</th>
</tr> <th>{{ t('table.competitor') }}</th>
</thead> <th v-if="showGroup">{{ t('table.group') }}</th>
<tbody> <th v-if="showScoreBeforeInput">{{ t('table.score') }}</th>
<tr v-for="(row, index) in filteredRows" :key="'desk-' + stage + '-' + row.playerId" class="score-group-row" :style="scoreGroupStyleFor(row)"> <th :class="{ 'stage-column-highlight': isHighlightedStage(stage, section) }">{{ inputLabel }}</th>
<td class="mono">{{ index + 1 }}</td> <th v-if="showProofControls">{{ t('table.verification') }}</th>
<td> <th v-if="showRank">{{ t('table.rank') }}</th>
<th v-if="showSeed">{{ t('table.seed') }}</th>
</tr>
</thead>
<tbody>
<tr
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>
<div class="competitor-cell compact">
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(row) }}</p>
<p class="name-sub">{{ secondaryName(row) }}</p>
</div>
</div>
</td>
<td v-if="showGroup" class="mono">{{ row.groupCode || t('labels.unassigned') }}</td>
<td v-if="showScoreBeforeInput" class="mono strong">{{ row.score }}</td>
<td :class="{ 'stage-column-highlight': isHighlightedStage(stage, section) }">
<input
class="score-input"
:class="{ 'score-input-over-limit': scoreInputInvalid(stage, row.playerId), 'score-input-stage-highlight': isHighlightedStage(stage, section) }"
type="number"
inputmode="numeric"
pattern="[0-9]*"
min="0"
max="100"
:value="scoreInputValue(stage, row.playerId)"
@focus="onScoreFocus(stage, row.playerId)"
@input="onScoreInput(stage, row.playerId, $event)"
@blur="onScoreCommit(stage, row.playerId)"
@keydown.enter.prevent="onScoreCommit(stage, row.playerId)"
/>
</td>
<td v-if="showRank" class="mono rank">{{ row.rank }}</td>
<td v-if="showSeed" class="mono">{{ row.seed }}</td>
</tr>
</tbody>
</table>
</div>
<div class="mobile-score-cards">
<article
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="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>
@@ -34,125 +88,60 @@
<p class="name-sub">{{ secondaryName(row) }}</p> <p class="name-sub">{{ secondaryName(row) }}</p>
</div> </div>
</div> </div>
</td> <div class="mono">#{{ index + 1 }}</div>
<td v-if="showGroup" class="mono">{{ row.groupCode || t('labels.unassigned') }}</td>
<td v-if="showScoreBeforeInput" class="mono strong">{{ row.score }}</td>
<td>
<input
class="score-input"
type="number"
inputmode="numeric"
pattern="[0-9]*"
min="0"
max="9999"
:value="scoreInputValue(stage, row.playerId)"
@focus="onScoreFocus(stage, row.playerId)"
@input="onScoreInput(stage, row.playerId, $event)"
@blur="onScoreCommit(stage, row.playerId)"
@keydown.enter.prevent="onScoreCommit(stage, row.playerId)"
/>
</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="showSeed" class="mono">{{ row.seed }}</td>
</tr>
<tr v-if="filteredRows.length === 0">
<td :colspan="columnCount" class="muted center">{{ t('labels.noPlayers') }}</td>
</tr>
</tbody>
</table>
</div>
<div class="mobile-score-cards">
<article v-for="(row, index) in filteredRows" :key="'mob-' + stage + '-' + row.playerId" class="score-card score-group-card" :style="scoreGroupStyleFor(row)">
<div class="score-card-head">
<div class="competitor-cell compact">
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(row) }}</p>
<p class="name-sub">{{ secondaryName(row) }}</p>
</div> </div>
</div>
<div class="mono">#{{ index + 1 }}</div> <div class="score-card-meta">
<span v-if="showGroup">{{ t('table.group') }}: {{ row.groupCode || t('labels.unassigned') }}</span>
<span v-if="showScoreBeforeInput">{{ t('table.score') }}: {{ row.score }}</span>
<span v-if="showRank">{{ t('table.rank') }}: {{ row.rank }}</span>
<span v-if="showSeed">{{ t('table.seed') }}: {{ row.seed }}</span>
</div>
<label class="score-label" :class="{ 'stage-column-highlight': isHighlightedStage(stage, section) }">{{ inputLabel }}</label>
<input
class="score-input"
:class="{ 'score-input-over-limit': scoreInputInvalid(stage, row.playerId), 'score-input-stage-highlight': isHighlightedStage(stage, section) }"
type="number"
inputmode="numeric"
pattern="[0-9]*"
min="0"
max="100"
:value="scoreInputValue(stage, row.playerId)"
@focus="onScoreFocus(stage, row.playerId)"
@input="onScoreInput(stage, row.playerId, $event)"
@blur="onScoreCommit(stage, row.playerId)"
@keydown.enter.prevent="onScoreCommit(stage, row.playerId)"
/>
<div v-if="showProofControls" class="proof-actions mobile-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-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>
</article>
</div> </div>
</template>
<div class="score-card-meta">
<span v-if="showGroup">{{ t('table.group') }}: {{ row.groupCode || t('labels.unassigned') }}</span>
<span v-if="showScoreBeforeInput">{{ t('table.score') }}: {{ row.score }}</span>
<span v-if="showRank">{{ t('table.rank') }}: {{ row.rank }}</span>
<span v-if="showSeed">{{ t('table.seed') }}: {{ row.seed }}</span>
</div>
<label class="score-label">{{ inputLabel }}</label>
<input
class="score-input"
type="number"
inputmode="numeric"
pattern="[0-9]*"
min="0"
max="9999"
:value="scoreInputValue(stage, row.playerId)"
@focus="onScoreFocus(stage, row.playerId)"
@input="onScoreInput(stage, row.playerId, $event)"
@blur="onScoreCommit(stage, row.playerId)"
@keydown.enter.prevent="onScoreCommit(stage, row.playerId)"
/>
<div class="proof-actions mobile-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>
</article>
<div v-if="filteredRows.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
</div> </div>
<div v-if="groupedSections.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed, ref, watch } from 'vue'
import { scoreGroupStyle, scoreValueStyle } from '../../utils/scoreGroupTheme' import { groupThemeStyleForKey, 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 },
@@ -166,10 +155,14 @@ 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 },
@@ -179,10 +172,14 @@ 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
@@ -193,19 +190,151 @@ const filteredRows = computed(() => {
}) })
}) })
const columnCount = computed(() => { const groupedSections = computed(() => {
let count = 4 if (!props.sectionByGroup) {
if (props.showGroup) count += 1 return [
if (props.showScoreBeforeInput) 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]),
}))
}) })
function scoreGroupStyleFor(row) { watch(
if (props.colorMode === 'score') { groupedSections,
return scoreValueStyle(row?.score) (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}`
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,6 +14,7 @@ export const I18N = {
liveTracker: 'LIVE TRACKER', liveTracker: 'LIVE TRACKER',
mode: 'الوضع', mode: 'الوضع',
language: 'اللغة', language: 'اللغة',
fullscreen: 'ملء الشاشة',
lastSync: 'آخر مزامنة', lastSync: 'آخر مزامنة',
loading: 'جاري تحميل بيانات البطولة...', loading: 'جاري تحميل بيانات البطولة...',
players: 'لاعب', players: 'لاعب',
@@ -23,11 +24,14 @@ 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: 'إظهار صور إثبات النتيجة في وضع العرض',
@@ -40,6 +44,7 @@ export const I18N = {
liveViewPrelimOverall: 'الترتيب العام للتمهيدي', liveViewPrelimOverall: 'الترتيب العام للتمهيدي',
liveViewFinalGroups: 'شاشة مجموعات النهائي', liveViewFinalGroups: 'شاشة مجموعات النهائي',
liveViewFinalTie: 'كسر تعادل النهائي', liveViewFinalTie: 'كسر تعادل النهائي',
liveViewFinalOverall: 'الترتيب العام للنهائي',
liveViewPodium: 'منصة التتويج', liveViewPodium: 'منصة التتويج',
showGroupLive: 'إظهار شاشة المجموعات الحية', showGroupLive: 'إظهار شاشة المجموعات الحية',
showPrelimTie: 'إظهار كسر تعادل التمهيدي', showPrelimTie: 'إظهار كسر تعادل التمهيدي',
@@ -56,6 +61,21 @@ 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: 'عرض اللاعبين والمجموعات',
@@ -92,6 +112,7 @@ export const I18N = {
competitor: 'اللاعب', competitor: 'اللاعب',
group: 'المجموعة', group: 'المجموعة',
rank: 'الترتيب', rank: 'الترتيب',
position: 'الموضع',
score: 'النتيجة', score: 'النتيجة',
total: 'المجموع', total: 'المجموع',
round1: 'جولة 1', round1: 'جولة 1',
@@ -120,6 +141,7 @@ export const I18N = {
removeImage: 'حذف الصورة', removeImage: 'حذف الصورة',
delete: 'حذف', delete: 'حذف',
resetScores: 'تصفير نتائج المرحلة', resetScores: 'تصفير نتائج المرحلة',
resetFinalGroupBySeed: 'إعادة مجموعات النهائي حسب التصنيف (1-6 / 7-12)',
resetOnlyScores: 'تصفير النتائج فقط', resetOnlyScores: 'تصفير النتائج فقط',
resetScoresAndProofs: 'تصفير النتائج وحذف الإثبات', resetScoresAndProofs: 'تصفير النتائج وحذف الإثبات',
randomEvenGroups: 'توزيع عشوائي متوازن', randomEvenGroups: 'توزيع عشوائي متوازن',
@@ -139,6 +161,11 @@ export const I18N = {
resetPosition: 'إعادة الضبط', resetPosition: 'إعادة الضبط',
liveModeRotate: 'تدوير تلقائي', liveModeRotate: 'تدوير تلقائي',
liveModeFixed: 'ثابت', liveModeFixed: 'ثابت',
enterFullscreen: 'دخول ملء الشاشة',
exitFullscreen: 'الخروج من ملء الشاشة',
startStage: 'بدء المرحلة',
endStage: 'إنهاء المرحلة',
goToStageScoring: 'الانتقال إلى إدخال نتائج المرحلة',
}, },
auth: { auth: {
username: 'اسم المستخدم', username: 'اسم المستخدم',
@@ -166,13 +193,14 @@ export const I18N = {
confirmDelete: 'هل تريد حذف اللاعب؟', confirmDelete: 'هل تريد حذف اللاعب؟',
confirmReset: 'هل تريد تصفير نتائج هذه المرحلة؟', confirmReset: 'هل تريد تصفير نتائج هذه المرحلة؟',
resetProofPrompt: 'هل تريد أيضًا حذف صور الإثبات لهذه المرحلة؟', resetProofPrompt: 'هل تريد أيضًا حذف صور الإثبات لهذه المرحلة؟',
invalidScore: 'النتيجة يجب أن تكون من 0 إلى 9999.', invalidScore: 'النتيجة يجب أن تكون من 0 إلى 100.',
unauthorized: 'انتهت صلاحية جلسة الإدارة. يرجى تسجيل الدخول مرة أخرى.', unauthorized: 'انتهت صلاحية جلسة الإدارة. يرجى تسجيل الدخول مرة أخرى.',
errorPrefix: 'حدث خطأ', errorPrefix: 'حدث خطأ',
noGroupsConfigured: 'لا توجد مجموعات أساسية مهيأة.', noGroupsConfigured: 'لا توجد مجموعات أساسية مهيأة.',
noNameToConvert: 'لا يوجد اسم للتحويل.', noNameToConvert: 'لا يوجد اسم للتحويل.',
aiAnalyzing: 'جاري تحليل الصورة بالذكاء الاصطناعي...', aiAnalyzing: 'جاري تحليل الصورة بالذكاء الاصطناعي...',
noProofForAi: 'لا توجد صورة إثبات لتحليلها.', noProofForAi: 'لا توجد صورة إثبات لتحليلها.',
noGroupForPhase: 'لا توجد مجموعات متاحة لهذا الطور.',
}, },
}, },
en: { en: {
@@ -190,6 +218,7 @@ 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',
@@ -199,11 +228,14 @@ 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',
@@ -216,6 +248,7 @@ 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',
@@ -232,6 +265,21 @@ 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',
@@ -268,6 +316,7 @@ 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',
@@ -296,6 +345,7 @@ 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',
@@ -315,6 +365,11 @@ 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',
@@ -342,13 +397,14 @@ 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 9999.', invalidScore: 'Score must be between 0 and 100.',
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.',
}, },
}, },
} }
@@ -392,6 +448,8 @@ 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,15 +1,24 @@
import { normalizedGroupCode } from './groups' import { normalizedGroupCode } from './groups'
const PRESET = { const PRESET = {
A: { accent: '#2f4d9a', soft: 'rgba(47, 77, 154, 0.10)', border: 'rgba(47, 77, 154, 0.34)' }, A: { accent: '#0d1931', soft: 'rgba(13, 25, 49, 0.14)', border: 'rgba(13, 25, 49, 0.44)' },
B: { accent: '#0d8fa5', soft: 'rgba(13, 143, 165, 0.10)', border: 'rgba(13, 143, 165, 0.34)' }, B: { accent: '#008ca8', soft: 'rgba(0, 140, 168, 0.14)', border: 'rgba(0, 140, 168, 0.44)' },
C: { accent: '#d54a3f', soft: 'rgba(213, 74, 63, 0.10)', border: 'rgba(213, 74, 63, 0.34)' }, C: { accent: '#ed2e23', soft: 'rgba(237, 46, 35, 0.14)', border: 'rgba(237, 46, 35, 0.44)' },
D: { accent: '#c2891f', soft: 'rgba(194, 137, 31, 0.12)', border: 'rgba(194, 137, 31, 0.36)' }, D: { accent: '#1b325f', soft: 'rgba(27, 50, 95, 0.14)', border: 'rgba(27, 50, 95, 0.42)' },
F1: { accent: '#5b4fc9', soft: 'rgba(91, 79, 201, 0.11)', border: 'rgba(91, 79, 201, 0.36)' }, F1: { accent: '#ed2e23', soft: 'rgba(237, 46, 35, 0.14)', border: 'rgba(237, 46, 35, 0.44)' },
F2: { accent: '#0f9f63', soft: 'rgba(15, 159, 99, 0.11)', border: 'rgba(15, 159, 99, 0.36)' }, F2: { accent: '#008ca8', soft: 'rgba(0, 140, 168, 0.14)', border: 'rgba(0, 140, 168, 0.44)' },
U: { accent: '#7f8ca8', soft: 'rgba(127, 140, 168, 0.11)', border: 'rgba(127, 140, 168, 0.34)' }, U: { accent: '#4c5f86', soft: 'rgba(76, 95, 134, 0.14)', border: 'rgba(76, 95, 134, 0.4)' },
} }
const THEME_PALETTE = [
{ accent: '#0d1931', soft: 'rgba(13, 25, 49, 0.15)', border: 'rgba(13, 25, 49, 0.46)' },
{ accent: '#008ca8', soft: 'rgba(0, 140, 168, 0.15)', border: 'rgba(0, 140, 168, 0.46)' },
{ accent: '#ed2e23', soft: 'rgba(237, 46, 35, 0.15)', border: 'rgba(237, 46, 35, 0.46)' },
{ accent: '#1b325f', soft: 'rgba(27, 50, 95, 0.15)', border: 'rgba(27, 50, 95, 0.44)' },
{ accent: '#0d5f72', soft: 'rgba(13, 95, 114, 0.15)', border: 'rgba(13, 95, 114, 0.44)' },
{ accent: '#b8261d', soft: 'rgba(184, 38, 29, 0.15)', border: 'rgba(184, 38, 29, 0.44)' },
]
export function scoreGroupToken(row) { 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'
@@ -30,12 +39,7 @@ export function scoreGroupStyle(row) {
} }
} }
const hue = hashToHue(token) return groupThemeStyleForKey(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) {
@@ -49,6 +53,26 @@ export function scoreValueStyle(scoreValue) {
} }
} }
export function groupThemeStyleForKey(key, options = {}) {
const raw = String(key || '').trim().toUpperCase()
const compact = raw.replace(/^[A-Z]/, '')
const numeric = Number.parseInt(compact || raw, 10)
const idx = Number.isFinite(numeric) && numeric > 0 ? (numeric - 1) % THEME_PALETTE.length : hashToHue(raw || 'U') % THEME_PALETTE.length
const base = THEME_PALETTE[idx]
if (!options.tieBreak) {
return {
'--score-group-accent': base.accent,
'--score-group-soft': base.soft,
'--score-group-border': base.border,
}
}
return {
'--score-group-accent': base.accent,
'--score-group-soft': base.soft.replace('0.15', '0.2'),
'--score-group-border': base.border,
}
}
function hashToHue(value) { function hashToHue(value) {
const input = String(value || 'U') const input = String(value || 'U')
let hash = 0 let hash = 0

137
getdata.json Normal file
View File

@@ -0,0 +1,137 @@
{
"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",
""
]
]
}

5
phoenix/.formatter.exs Normal file
View File

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

31
phoenix/.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# 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-*

111
phoenix/AGENTS.md Normal file
View File

@@ -0,0 +1,111 @@
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 -->

51
phoenix/README.md Normal file
View File

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

35
phoenix/config/config.exs Normal file
View File

@@ -0,0 +1,35 @@
# This file is responsible for configuring your application
# and its dependencies with the aid of the Config module.
#
# This configuration file is loaded before any dependency and
# is restricted to this project.
# General application configuration
import Config
config :shooting_event_phx,
ecto_repos: [ShootingEventPhx.Repo],
generators: [timestamp_type: :utc_datetime]
# Configure the endpoint
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
url: [host: "localhost"],
adapter: Bandit.PhoenixAdapter,
render_errors: [
formats: [json: ShootingEventPhxWeb.ErrorJSON],
layout: false
],
pubsub_server: ShootingEventPhx.PubSub,
live_view: [signing_salt: "FIh+7Vu7"]
# Configure Elixir's Logger
config :logger, :default_formatter,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"

60
phoenix/config/dev.exs Normal file
View File

@@ -0,0 +1,60 @@
import Config
# Configure your database
config :shooting_event_phx, ShootingEventPhx.Repo,
database: Path.expand("../shooting_event_phx_dev.db", __DIR__),
pool_size: 5,
stacktrace: true,
show_sensitive_data_on_connection_error: true
# For development, we disable any cache and enable
# debugging and code reloading.
#
# The watchers configuration can be used to run external
# watchers to your application. For example, we can use it
# to bundle .js and .css sources.
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
# Binding to loopback ipv4 address prevents access from other machines.
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
http: [ip: {127, 0, 0, 1}],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "lw8ztXfcXK/Q4GVw+LtQGEo34zfdV+uCkmdj2BkwfDlxIZzE8jQyMbU8AqKNBoVW",
watchers: []
# ## SSL Support
#
# In order to use HTTPS in development, a self-signed
# certificate can be generated by running the following
# Mix task:
#
# mix phx.gen.cert
#
# Run `mix help phx.gen.cert` for more information.
#
# The `http:` config above can be replaced with:
#
# https: [
# port: 4001,
# cipher_suite: :strong,
# keyfile: "priv/cert/selfsigned_key.pem",
# certfile: "priv/cert/selfsigned.pem"
# ],
#
# If desired, both `http:` and `https:` keys can be
# configured to run both http and https servers on
# different ports.
# Enable dev routes for dashboard and mailbox
config :shooting_event_phx, dev_routes: true
# Do not include metadata nor timestamps in development logs
config :logger, :default_formatter, format: "[$level] $message\n"
# Set a higher stacktrace during development. Avoid configuring such
# in production as building large stacktraces may be expensive.
config :phoenix, :stacktrace_depth, 20
# Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime

19
phoenix/config/prod.exs Normal file
View File

@@ -0,0 +1,19 @@
import Config
# Force using SSL in production. This also sets the "strict-security-transport" header,
# known as HSTS. If you have a health check endpoint, you may want to exclude it below.
# Note `:force_ssl` is required to be set at compile-time.
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
force_ssl: [
rewrite_on: [:x_forwarded_proto],
exclude: [
# paths: ["/health"],
hosts: ["localhost", "127.0.0.1"]
]
]
# Do not print debug messages in production
config :logger, level: :info
# Runtime production configuration, including reading
# of environment variables, is done on config/runtime.exs.

View File

@@ -0,0 +1,92 @@
import Config
# config/runtime.exs is executed for all environments, including
# during releases. It is executed after compilation and before the
# system starts, so it is typically used to load production configuration
# and secrets from environment variables or elsewhere. Do not define
# any compile-time configuration in here, as it won't be applied.
# The block below contains prod specific runtime configuration.
# ## Using releases
#
# If you use `mix release`, you need to explicitly enable the server
# by passing the PHX_SERVER=true when you start it:
#
# PHX_SERVER=true bin/shooting_event_phx start
#
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
# script that automatically sets the env var above.
if System.get_env("PHX_SERVER") do
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint, server: true
end
default_db_path = Path.expand("../../data/shooting_phoenix.db", __DIR__)
database_path = System.get_env("DB_PATH") || System.get_env("DATABASE_PATH") || default_db_path
config :shooting_event_phx, ShootingEventPhx.Repo,
database: database_path,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5")
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
http: [ip: {0, 0, 0, 0}, port: String.to_integer(System.get_env("PORT", "8080"))]
if config_env() == :prod do
# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
# want to use a different value for prod and you most likely don't want
# to check this value into version control, so we use an environment
# variable instead.
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise """
environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret
"""
host = System.get_env("PHX_HOST") || "example.com"
config :shooting_event_phx, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [
# Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0}
],
secret_key_base: secret_key_base
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
# to your endpoint configuration:
#
# config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
# https: [
# ...,
# port: 443,
# cipher_suite: :strong,
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
# ]
#
# The `cipher_suite` is set to `:strong` to support only the
# latest and more secure SSL ciphers. This means old browsers
# and clients may not be supported. You can set it to
# `:compatible` for wider support.
#
# `:keyfile` and `:certfile` expect an absolute path to the key
# and cert in disk or a relative path inside priv, for example
# "priv/ssl/server.key". For all supported SSL configuration
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
#
# We also recommend setting `force_ssl` in your config/prod.exs,
# ensuring no data is ever sent via http, always redirecting to https:
#
# config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
end

28
phoenix/config/test.exs Normal file
View File

@@ -0,0 +1,28 @@
import Config
# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used
# to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information.
config :shooting_event_phx, ShootingEventPhx.Repo,
database: Path.expand("../shooting_event_phx_test.db", __DIR__),
pool_size: 5,
pool: Ecto.Adapters.SQL.Sandbox
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4002],
secret_key_base: "h/THyq4VyIU51PLFwsrgMS2n9bs3Dj+RkC7my5e/FhUC91q7EUB3WcYfdvEKJgX3",
server: false
# Print only warnings and errors during test
config :logger, level: :warning
# Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime
# Sort query params output of verified routes for robust url comparisons
config :phoenix,
sort_verified_routes_query_params: true

View File

@@ -0,0 +1,9 @@
defmodule ShootingEventPhx do
@moduledoc """
ShootingEventPhx keeps the contexts that define your domain
and business logic.
Contexts are also responsible for managing your data, regardless
if it comes from the database, an external API or others.
"""
end

View File

@@ -0,0 +1,199 @@
defmodule ShootingEventPhx.AI.Gemini do
@moduledoc false
def generate_score_advice(stage, player_id, image_data, current_score) do
with api_key when is_binary(api_key) and api_key != "" <- gemini_api_key(),
{:ok, mime_type, raw_base64} <- parse_data_uri(image_data),
{:ok, body} <- build_request_body(stage, current_score, mime_type, raw_base64),
{:ok, response} <- request_gemini(body, api_key),
{:ok, model_response} <- parse_gemini_response(response) do
{:ok,
%{
"stage" => stage,
"playerId" => player_id,
"advisedScore" => clamp(model_response["advisedScore"] || 0, 0, 9999),
"reason" => normalize_reason(model_response["reason"]),
"generatedAt" =>
DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
}}
else
nil -> {:error, :service_unavailable, "gemini api key is not configured"}
{:error, message} when is_binary(message) -> {:error, :bad_gateway, message}
{:error, status, message} -> {:error, status, message}
_ -> {:error, :bad_gateway, "failed to generate score advice"}
end
end
defp gemini_api_key do
System.get_env("GEMINI_API_KEY", "AIzaSyATpv4fmHpjPPLk-BEy4fCBL_r1EWtiWDc")
|> String.trim()
|> case do
"" -> nil
v -> v
end
end
defp gemini_model do
System.get_env("GEMINI_MODEL", "gemini-3.1-flash-lite-preview")
|> String.trim()
|> case do
"" -> "gemini-3.1-flash-lite-preview"
v -> v
end
end
defp parse_data_uri(data_uri) do
value = data_uri |> to_string() |> String.trim()
if String.starts_with?(value, "data:") do
case String.split(value, ",", parts: 2) do
[header, payload] ->
payload = String.trim(payload)
if payload == "" do
{:error, "invalid proof image data: empty payload"}
else
mime_type =
case String.split(header, ";", parts: 2) do
["data:" <> mime, _] when mime != "" -> mime
_ -> "image/jpeg"
end
{:ok, mime_type, payload}
end
_ ->
{:error, "invalid proof image data: invalid data uri format"}
end
else
{:error, "invalid proof image data: expected data uri"}
end
end
defp build_request_body(stage, current_score, mime_type, raw_base64) do
request = %{
"contents" => [
%{
"role" => "user",
"parts" => [
%{"text" => score_advice_prompt(stage, current_score)},
%{"inline_data" => %{"mime_type" => mime_type, "data" => raw_base64}}
]
}
],
"generationConfig" => %{
"temperature" => 0,
"responseMimeType" => "application/json",
"maxOutputTokens" => 100
}
}
{:ok, Jason.encode!(request)}
end
defp request_gemini(body, api_key) do
endpoint =
"https://generativelanguage.googleapis.com/v1beta/models/#{URI.encode(gemini_model())}:generateContent?key=#{URI.encode_www_form(api_key)}"
case Req.post(endpoint,
headers: [{"content-type", "application/json"}],
body: body,
receive_timeout: 25_000
) do
{:ok, %Req.Response{status: status, body: response_body}} when status < 300 ->
{:ok, response_body}
{:ok, %Req.Response{status: status, body: response_body}} ->
{:error, "gemini api status #{status}: #{inspect(response_body)}"}
{:error, reason} ->
{:error, "call gemini api: #{inspect(reason)}"}
end
end
defp parse_gemini_response(response_body) when is_map(response_body) do
cond do
is_map(response_body["error"]) and is_binary(response_body["error"]["message"]) ->
{:error, "gemini api error: #{response_body["error"]["message"]}"}
true ->
text =
get_in(response_body, [
"candidates",
Access.at(0),
"content",
"parts",
Access.at(0),
"text"
])
|> to_string()
|> String.trim()
json_text = extract_json_object(text)
if json_text == "" do
{:error, "gemini response was not valid json"}
else
case Jason.decode(json_text) do
{:ok, parsed} -> {:ok, parsed}
{:error, reason} -> {:error, "parse gemini advice json: #{inspect(reason)}"}
end
end
end
end
defp parse_gemini_response(_), do: {:error, "decode gemini response: unexpected payload"}
defp extract_json_object(raw) do
text =
raw
|> String.trim()
|> String.trim_leading("```json")
|> String.trim_leading("```")
|> String.trim_trailing("```")
|> String.trim()
start_idx = binary_index(text, "{")
end_idx = binary_rindex(text, "}")
if is_integer(start_idx) and is_integer(end_idx) and end_idx > start_idx do
text
|> String.slice(start_idx..end_idx)
|> String.trim()
else
""
end
end
defp score_advice_prompt(stage, current_score) do
"Target scoring assistant.\nStage: #{stage}. Current score: #{current_score}.\n" <>
"Return STRICT JSON only:\n{\"advisedScore\":<int 0..9999>,\"reason\":\"<max 18 words>\"}\n" <>
"Do not add markdown or extra fields."
end
defp normalize_reason(reason) do
value = reason |> to_string() |> String.trim()
if value == "", do: "AI estimated the score from visible impacts.", else: value
end
defp clamp(value, min, _max) when value < min, do: min
defp clamp(value, _min, max) when value > max, do: max
defp clamp(value, _min, _max), do: value
defp binary_index(text, pattern) do
case :binary.match(text, pattern) do
:nomatch -> nil
{idx, _len} -> idx
end
end
defp binary_rindex(text, pattern) do
text
|> :binary.matches(pattern)
|> List.last()
|> case do
nil -> nil
{idx, _len} -> idx
end
end
end

View File

@@ -0,0 +1,43 @@
defmodule ShootingEventPhx.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
@impl true
def start(_type, _args) do
children = [
ShootingEventPhxWeb.Telemetry,
ShootingEventPhx.Repo,
{Ecto.Migrator,
repos: Application.fetch_env!(:shooting_event_phx, :ecto_repos), skip: skip_migrations?()},
{DNSCluster,
query: Application.get_env(:shooting_event_phx, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: ShootingEventPhx.PubSub},
ShootingEventPhx.Auth.SessionStore,
# Start a worker by calling: ShootingEventPhx.Worker.start_link(arg)
# {ShootingEventPhx.Worker, arg},
# Start to serve requests, typically the last entry
ShootingEventPhxWeb.Endpoint
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: ShootingEventPhx.Supervisor]
Supervisor.start_link(children, opts)
end
# Tell Phoenix to update the endpoint configuration
# whenever the application is updated.
@impl true
def config_change(changed, _new, removed) do
ShootingEventPhxWeb.Endpoint.config_change(changed, removed)
:ok
end
defp skip_migrations?() do
# By default, sqlite migrations are run when using a release
System.get_env("RELEASE_NAME") == nil
end
end

View File

@@ -0,0 +1,51 @@
defmodule ShootingEventPhx.Auth do
@moduledoc false
alias ShootingEventPhx.Auth.SessionStore
def admin_user, do: System.get_env("ADMIN_USER", "datwyler")
def admin_pass, do: System.get_env("ADMIN_PASS", "datwyler")
def verify_admin(username, password) do
u = username |> to_string() |> String.trim()
p = password |> to_string() |> String.trim()
if u == "" or p == "" do
false
else
secure_compare(u, admin_user()) and secure_compare(p, admin_pass())
end
end
def bearer_token(conn) do
conn
|> Plug.Conn.get_req_header("authorization")
|> List.first()
|> parse_bearer_token()
end
def admin_request?(conn) do
case bearer_token(conn) do
nil -> false
token -> SessionStore.validate_token(token)
end
end
defp parse_bearer_token(nil), do: nil
defp parse_bearer_token(header) do
value = String.trim(header || "")
if String.starts_with?(String.downcase(value), "bearer ") do
token = value |> String.slice(7..-1//1) |> String.trim()
if token == "", do: nil, else: token
else
nil
end
end
defp secure_compare(left, right) do
Plug.Crypto.secure_compare(left, right)
rescue
_ -> false
end
end

View File

@@ -0,0 +1,46 @@
defmodule ShootingEventPhx.Auth.SessionStore do
@moduledoc false
use Agent
@duration_seconds 8 * 60 * 60
def start_link(_opts) do
Agent.start_link(fn -> %{} end, name: __MODULE__)
end
def create_token do
token = :crypto.strong_rand_bytes(32) |> Base.encode16(case: :lower)
expires_at = DateTime.add(DateTime.utc_now(), @duration_seconds, :second)
Agent.update(__MODULE__, &Map.put(&1, token, expires_at))
{:ok, token, expires_at}
end
def validate_token(token) when is_binary(token) do
purge_expired()
Agent.get(__MODULE__, fn tokens ->
case Map.get(tokens, token) do
%DateTime{} = expires_at -> DateTime.compare(DateTime.utc_now(), expires_at) == :lt
_ -> false
end
end)
end
def delete_token(token) when is_binary(token) do
Agent.update(__MODULE__, &Map.delete(&1, token))
end
def purge_expired do
now = DateTime.utc_now()
Agent.update(__MODULE__, fn tokens ->
Enum.reduce(tokens, %{}, fn {token, expires_at}, acc ->
if DateTime.compare(now, expires_at) == :lt do
Map.put(acc, token, expires_at)
else
acc
end
end)
end)
end
end

View File

@@ -0,0 +1,11 @@
defmodule ShootingEventPhx.Domain.AppSetting do
use Ecto.Schema
@primary_key {:key, :string, []}
@timestamps_opts [inserted_at: false, updated_at: :updated_at, type: :utc_datetime]
schema "app_settings" do
field :value, :string, default: ""
timestamps()
end
end

View File

@@ -0,0 +1,272 @@
defmodule ShootingEventPhx.Domain.Derived do
@moduledoc false
alias ShootingEventPhx.Domain.Stages
def compute(players, scores) do
player_by_id = Map.new(players, &{&1.id, &1})
pre_rows =
players
|> Enum.map(fn p ->
%{
"playerId" => p.id,
"nameAr" => p.name_ar,
"nameEn" => p.name_en,
"groupCode" => p.group_code,
"imageData" => p.image_data,
"score" => score_total(scores, p.id, Stages.preliminary_round_stages()),
"tieBreak" => get_score(scores, "prelim_tiebreak", p.id),
"rank" => 0,
"seed" => 0,
"finalGroup" => 0
}
end)
|> Enum.sort_by(fn row -> {-row["score"], row["playerId"]} end)
|> assign_dense_rank_by_score()
{pre_tie, finalists} = compute_finalists(pre_rows)
finalists =
finalists
|> Enum.with_index(1)
|> Enum.map(fn {row, idx} ->
row
|> Map.put("seed", idx)
|> Map.put("finalGroup", if(idx <= 6, do: 1, else: 2))
end)
final_group1 = Enum.filter(finalists, &(&1["finalGroup"] == 1))
final_group2 = Enum.filter(finalists, &(&1["finalGroup"] == 2))
final_rows =
finalists
|> Enum.map(fn finalist ->
p = Map.fetch!(player_by_id, finalist["playerId"])
%{
"playerId" => finalist["playerId"],
"nameAr" => p.name_ar,
"nameEn" => p.name_en,
"groupCode" => p.group_code,
"imageData" => p.image_data,
"score" => score_total(scores, finalist["playerId"], Stages.final_round_stages()),
"tieBreak" => get_score(scores, "final_tiebreak", finalist["playerId"]),
"rank" => 0,
"seed" => finalist["seed"],
"finalGroup" => finalist["finalGroup"]
}
end)
|> compute_final_ranking()
podium = final_rows |> Enum.take(3)
final_tie = final_tie_info(final_rows)
%{
"preliminaryRanking" => %{
"rows" => pre_rows,
"tieBreak" => pre_tie,
"unresolved" => pre_tie["required"] and not pre_tie["resolved"]
},
"finalists" => finalists,
"finalGroups" => %{"group1" => final_group1, "group2" => final_group2},
"finalRanking" => %{
"rows" => final_rows,
"tieBreak" => final_tie,
"unresolved" => final_tie["required"] and not final_tie["resolved"]
},
"podium" => podium
}
end
defp compute_finalists(pre_rows) do
if length(pre_rows) <= 12 do
finalists =
pre_rows
|> Enum.with_index(1)
|> Enum.map(fn {row, seed} -> Map.put(row, "seed", seed) end)
{empty_tie(), finalists}
else
cutoff = Enum.at(pre_rows, 11)["score"]
above = Enum.filter(pre_rows, &(&1["score"] > cutoff))
at_cutoff = Enum.filter(pre_rows, &(&1["score"] == cutoff))
slots = max(12 - length(above), 0)
if length(at_cutoff) <= slots do
{empty_tie(), above ++ at_cutoff}
else
tie = %{
"required" => true,
"resolved" => true,
"slots" => slots,
"playerIds" => at_cutoff |> Enum.map(& &1["playerId"]) |> Enum.sort()
}
sorted_at_cutoff =
Enum.sort_by(at_cutoff, fn row -> {-row["tieBreak"], row["playerId"]} end)
tie =
if slots > 0 do
boundary = Enum.at(sorted_at_cutoff, slots - 1)["tieBreak"]
greater = Enum.count(sorted_at_cutoff, &(&1["tieBreak"] > boundary))
equal = Enum.count(sorted_at_cutoff, &(&1["tieBreak"] == boundary))
resolved = not (greater < slots and greater + equal > slots)
Map.put(tie, "resolved", resolved)
else
tie
end
finalists = above ++ Enum.take(sorted_at_cutoff, min(slots, length(sorted_at_cutoff)))
finalists =
finalists
|> Enum.sort_by(fn row ->
if tie["required"] do
{-row["score"], -row["tieBreak"], row["playerId"]}
else
{-row["score"], row["playerId"]}
end
end)
|> assign_dense_rank(fn a, b ->
if tie["required"],
do: a["score"] == b["score"] and a["tieBreak"] == b["tieBreak"],
else: a["score"] == b["score"]
end)
{tie, finalists}
end
end
end
defp compute_final_ranking(rows) do
sorted = Enum.sort_by(rows, fn row -> {-row["score"], row["seed"]} end)
tied_top = tied_top_for_podium(sorted)
rows =
if MapSet.size(tied_top) > 0 do
Enum.sort_by(sorted, fn row ->
tied = MapSet.member?(tied_top, row["playerId"])
tie_break_key = if tied, do: -row["tieBreak"], else: 0
{-row["score"], tie_break_key, row["seed"]}
end)
else
sorted
end
if MapSet.size(tied_top) > 0 do
assign_dense_rank(rows, fn a, b ->
if a["score"] != b["score"] do
false
else
tied_a = MapSet.member?(tied_top, a["playerId"])
tied_b = MapSet.member?(tied_top, b["playerId"])
if tied_a and tied_b, do: a["tieBreak"] == b["tieBreak"], else: true
end
end)
else
assign_dense_rank_by_score(rows)
end
end
defp final_tie_info(final_rows) do
tied_top = tied_top_for_podium(final_rows)
if MapSet.size(tied_top) == 0 do
empty_tie()
else
player_ids = tied_top |> MapSet.to_list() |> Enum.sort()
resolved =
if length(final_rows) >= 3 do
third = Enum.at(final_rows, 2)
{greater, equal} =
Enum.reduce(final_rows, {0, 0}, fn row, {g, e} ->
cond do
row["score"] > third["score"] ->
{g + 1, e}
row["score"] < third["score"] ->
{g, e}
true ->
row_tied = MapSet.member?(tied_top, row["playerId"])
third_tied = MapSet.member?(tied_top, third["playerId"])
cond do
row_tied and third_tied and row["tieBreak"] > third["tieBreak"] -> {g + 1, e}
row_tied and third_tied and row["tieBreak"] == third["tieBreak"] -> {g, e + 1}
not row_tied or not third_tied -> {g, e + 1}
true -> {g, e}
end
end
end)
not (greater < 3 and greater + equal > 3)
else
true
end
%{"required" => true, "resolved" => resolved, "slots" => 0, "playerIds" => player_ids}
end
end
defp tied_top_for_podium(rows) do
rows
|> Enum.chunk_by(& &1["score"])
|> Enum.reduce({MapSet.new(), 1}, fn chunk, {set, start_pos} ->
size = length(chunk)
end_pos = start_pos + size - 1
score = List.first(chunk)["score"]
include? =
size > 1 and score > 0 and
(start_pos <= 3 or end_pos <= 3 or (start_pos < 3 and end_pos > 3))
next_set =
if include? do
Enum.reduce(chunk, set, fn row, acc -> MapSet.put(acc, row["playerId"]) end)
else
set
end
{next_set, end_pos + 1}
end)
|> elem(0)
end
defp empty_tie, do: %{"required" => false, "resolved" => true, "slots" => 0, "playerIds" => []}
defp score_total(scores, player_id, stages) do
Enum.reduce(stages, 0, fn stage, acc -> acc + get_score(scores, stage, player_id) end)
end
defp get_score(scores, stage, player_id) do
stage_map = Map.get(scores, stage, %{})
Map.get(stage_map, player_id, 0)
end
defp assign_dense_rank_by_score(rows) do
assign_dense_rank(rows, fn a, b -> a["score"] == b["score"] end)
end
defp assign_dense_rank([], _equal_fun), do: []
defp assign_dense_rank(rows, equal_fun) do
{ranked_rev, _prev, _rank, _idx} =
Enum.reduce(rows, {[], nil, 1, 0}, fn row, {acc, prev, rank, idx} ->
idx1 = idx + 1
next_rank =
if is_map(prev) and equal_fun.(row, prev) do
rank
else
idx1
end
{[%{row | "rank" => next_rank} | acc], row, next_rank, idx1}
end)
Enum.reverse(ranked_rev)
end
end

View File

@@ -0,0 +1,353 @@
defmodule ShootingEventPhx.Domain.EventData do
@moduledoc false
import Ecto.Query
alias ShootingEventPhx.Repo
alias ShootingEventPhx.Domain.{Player, Score, ScoreAttachment, Stages}
def list_players do
Repo.all(from p in Player, order_by: [asc: p.id])
end
def ensure_score_rows(players) do
Repo.transaction(fn ->
for player <- players, stage <- Stages.score_stages() do
Repo.query!(
"""
INSERT OR IGNORE INTO scores(stage, player_id, score)
VALUES(?1, ?2, 0)
""",
[stage, player.id]
)
end
end)
end
def read_scores(players) do
base =
for stage <- Stages.score_stages(), into: %{} do
{stage, Map.new(players, &{&1.id, 0})}
end
rows =
Repo.all(
from s in Score,
select: {s.stage, s.player_id, s.score}
)
Enum.reduce(rows, base, fn {stage, player_id, score}, acc ->
if Map.has_key?(acc, stage) do
Map.update!(acc, stage, &Map.put(&1, player_id, score))
else
acc
end
end)
end
def read_score_proofs(players) do
base =
for stage <- Stages.score_stages(), into: %{} do
{stage, Map.new(players, &{&1.id, ""})}
end
rows =
Repo.all(
from sa in ScoreAttachment,
select: {sa.stage, sa.player_id, sa.image_data}
)
Enum.reduce(rows, base, fn {stage, player_id, image_data}, acc ->
if Map.has_key?(acc, stage) do
Map.update!(acc, stage, &Map.put(&1, player_id, image_data || ""))
else
acc
end
end)
end
def score_map_to_json(scores) do
for {stage, stage_map} <- scores, into: %{} do
{stage,
for({player_id, value} <- stage_map, into: %{}, do: {Integer.to_string(player_id), value})}
end
end
def proof_map_to_json(proofs) do
for {stage, stage_map} <- proofs, into: %{} do
compact =
for {player_id, image_data} <- stage_map,
String.trim(to_string(image_data)) != "",
into: %{} do
{Integer.to_string(player_id), image_data}
end
{stage, compact}
end
end
def create_player(attrs) do
name_ar = attrs["nameAr"] |> to_string() |> String.trim()
name_en = attrs["nameEn"] |> to_string() |> String.trim()
group_code = attrs["groupCode"] |> normalize_group()
cond do
name_ar == "" or name_en == "" ->
{:error, :bad_request, "both Arabic and English names are required"}
true ->
Repo.transaction(fn ->
{:ok, player} =
%Player{name_ar: name_ar, name_en: name_en, group_code: group_code, image_data: ""}
|> Repo.insert()
for stage <- Stages.score_stages() do
Repo.query!(
"""
INSERT OR IGNORE INTO scores(stage, player_id, score) VALUES(?1, ?2, 0)
""",
[stage, player.id]
)
end
end)
|> case do
{:ok, _} -> :ok
{:error, reason} -> {:error, :internal, inspect(reason)}
end
end
end
def update_player(id, attrs) do
with %Player{} = player <- Repo.get(Player, id) do
updates = player_updates(attrs)
cond do
Map.has_key?(updates, :_error) ->
{:error, :bad_request, Map.get(updates, :_error)}
map_size(updates) == 0 ->
{:error, :bad_request, "no fields to update"}
true ->
case Repo.update(Ecto.Changeset.change(player, updates)) do
{:ok, _} -> :ok
{:error, changeset} -> {:error, :bad_request, format_changeset_errors(changeset)}
end
end
else
nil -> {:error, :not_found, "player not found"}
end
end
def delete_player(id) do
case Repo.get(Player, id) do
nil ->
{:error, :not_found, "player not found"}
player ->
case Repo.delete(player) do
{:ok, _} -> :ok
{:error, reason} -> {:error, :internal, inspect(reason)}
end
end
end
def auto_group_players(groups) do
normalized_groups =
groups
|> List.wrap()
|> Enum.map(&normalize_group/1)
|> Enum.reject(&(&1 == ""))
|> Enum.uniq()
if normalized_groups == [] do
{:error, :bad_request, "at least one group is required"}
else
player_ids = Repo.all(from p in Player, select: p.id, order_by: [asc: p.id])
shuffled = Enum.shuffle(player_ids)
case Repo.transaction(fn ->
Enum.with_index(shuffled)
|> Enum.each(fn {player_id, idx} ->
group_code = Enum.at(normalized_groups, rem(idx, length(normalized_groups)))
Repo.query!(
"UPDATE players SET group_code = ?1, updated_at = CURRENT_TIMESTAMP WHERE id = ?2",
[group_code, player_id]
)
end)
end) do
{:ok, _} -> :ok
{:error, reason} -> {:error, :internal, inspect(reason)}
end
end
end
def update_score(stage, player_id, score) when is_integer(score) do
if score < 0 or score > 9999 do
{:error, :bad_request, "score must be between 0 and 9999"}
else
Repo.query!(
"""
INSERT INTO scores(stage, player_id, score)
VALUES(?1, ?2, ?3)
ON CONFLICT(stage, player_id) DO UPDATE SET score = excluded.score, updated_at = CURRENT_TIMESTAMP
""",
[stage, player_id, score]
)
:ok
end
end
def update_score_proof(stage, player_id, image_data) do
payload = to_string(image_data || "") |> String.trim()
cond do
payload == "" ->
{:error, :bad_request, "imageData is required"}
byte_size(payload) > 5_000_000 ->
{:error, :bad_request, "image payload too large"}
true ->
Repo.query!(
"""
INSERT INTO score_attachments(stage, player_id, image_data)
VALUES(?1, ?2, ?3)
ON CONFLICT(stage, player_id) DO UPDATE SET image_data = excluded.image_data, updated_at = CURRENT_TIMESTAMP
""",
[stage, player_id, payload]
)
:ok
end
end
def delete_score_proof(stage, player_id) do
Repo.query!("DELETE FROM score_attachments WHERE stage = ?1 AND player_id = ?2", [
stage,
player_id
])
:ok
end
def reset_stage_scores(stages, reset_proofs?) do
case Repo.transaction(fn ->
Enum.each(stages, fn stage ->
Repo.query!(
"UPDATE scores SET score = 0, updated_at = CURRENT_TIMESTAMP WHERE stage = ?1",
[stage]
)
if reset_proofs? do
Repo.query!("DELETE FROM score_attachments WHERE stage = ?1", [stage])
end
end)
end) do
{:ok, _} -> :ok
{:error, reason} -> {:error, :internal, inspect(reason)}
end
end
def load_score_proof_image(stage, player_id) do
case Repo.one(
from sa in ScoreAttachment,
where: sa.stage == ^stage and sa.player_id == ^player_id,
select: sa.image_data
) do
nil -> {:error, :not_found}
image -> {:ok, image}
end
end
def load_current_score(stage, player_id) do
score =
Repo.one(
from s in Score, where: s.stage == ^stage and s.player_id == ^player_id, select: s.score
)
{:ok, score || 0}
end
defp player_updates(attrs) do
updates = %{}
updates =
case Map.get(attrs, "nameAr") do
nil ->
updates
name ->
trimmed = to_string(name) |> String.trim()
if trimmed == "",
do: Map.put(updates, :_error, "arabic name cannot be empty"),
else: Map.put(updates, :name_ar, trimmed)
end
updates =
case Map.get(attrs, "nameEn") do
nil ->
updates
name ->
trimmed = to_string(name) |> String.trim()
if trimmed == "",
do: Map.put(updates, :_error, "english name cannot be empty"),
else: Map.put(updates, :name_en, trimmed)
end
updates =
if Map.has_key?(attrs, "groupCode"),
do: Map.put(updates, :group_code, normalize_group(Map.get(attrs, "groupCode"))),
else: updates
updates =
if Map.has_key?(attrs, "imageData") do
image = attrs |> Map.get("imageData") |> to_string() |> String.trim()
Map.put(updates, :image_data, image)
else
updates
end
updates =
if truthy?(Map.get(attrs, "clearImage")) do
Map.put(updates, :image_data, "")
else
updates
end
case Map.pop(updates, :_error) do
{nil, sanitized} ->
if byte_size(Map.get(sanitized, :image_data, "")) > 2_500_000 do
%{_error: "image payload too large"}
else
sanitized
end
{message, _} ->
%{_error: message}
end
end
defp normalize_group(group), do: group |> to_string() |> String.trim()
defp truthy?(value) do
case value do
true -> true
1 -> true
"1" -> true
"true" -> true
"yes" -> true
"on" -> true
_ -> false
end
end
defp format_changeset_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
|> Enum.map(fn {field, msgs} -> "#{field} #{Enum.join(msgs, ",")}" end)
|> Enum.join("; ")
end
end

View File

@@ -0,0 +1,14 @@
defmodule ShootingEventPhx.Domain.Player do
use Ecto.Schema
@primary_key {:id, :id, autogenerate: true}
@timestamps_opts [inserted_at: :created_at, updated_at: :updated_at, type: :utc_datetime]
schema "players" do
field :name_ar, :string
field :name_en, :string
field :group_code, :string, default: ""
field :image_data, :string, default: ""
timestamps()
end
end

View File

@@ -0,0 +1,13 @@
defmodule ShootingEventPhx.Domain.Score do
use Ecto.Schema
@primary_key false
@timestamps_opts [inserted_at: false, updated_at: :updated_at, type: :utc_datetime]
schema "scores" do
field :stage, :string, primary_key: true
field :player_id, :id, primary_key: true
field :score, :integer, default: 0
timestamps()
end
end

View File

@@ -0,0 +1,13 @@
defmodule ShootingEventPhx.Domain.ScoreAttachment do
use Ecto.Schema
@primary_key false
@timestamps_opts [inserted_at: false, updated_at: :updated_at, type: :utc_datetime]
schema "score_attachments" do
field :stage, :string, primary_key: true
field :player_id, :id, primary_key: true
field :image_data, :string, default: ""
timestamps()
end
end

View File

@@ -0,0 +1,297 @@
defmodule ShootingEventPhx.Domain.Settings do
@moduledoc false
import Ecto.Query
alias ShootingEventPhx.Repo
alias ShootingEventPhx.Domain.AppSetting
@setting_view_proof_in_view "view_proof_in_view"
@setting_live_active_view "live_active_view"
@setting_live_show_group_live "live_show_group_live"
@setting_live_group_display_mode "live_group_display_mode"
@setting_live_group_fixed_code "live_group_fixed_code"
@setting_live_show_prelim_tie "live_show_prelim_tie"
@setting_live_show_prelim_overall "live_show_prelim_overall"
@setting_live_show_final_groups "live_show_final_groups"
@setting_live_final_display_mode "live_final_display_mode"
@setting_live_final_fixed_group "live_final_fixed_group"
@setting_live_show_final_tie "live_show_final_tie"
@setting_live_show_podium "live_show_podium"
@setting_live_rotation_interval "live_rotation_interval_sec"
@setting_live_rotation_players "live_rotation_player_count"
@allowed_live_views MapSet.new([
"group_live",
"prelim_tie",
"prelim_overall",
"final_groups",
"final_tie",
"podium"
])
def default_live_mode do
%{
"activeView" => "group_live",
"showGroupLive" => true,
"groupDisplayMode" => "rotate",
"groupFixedCode" => "",
"showPrelimTie" => true,
"showPrelimOverall" => true,
"showFinalGroups" => true,
"finalDisplayMode" => "rotate",
"finalFixedGroup" => 1,
"showFinalTie" => true,
"showPodium" => true,
"rotationIntervalSec" => 5,
"rotationPlayerCount" => 12
}
end
def read_settings do
all =
Repo.all(from s in AppSetting, select: {s.key, s.value})
|> Map.new()
live = default_live_mode()
live =
live
|> Map.put("showGroupLive", setting_bool(Map.get(all, @setting_live_show_group_live, "1")))
|> Map.put(
"activeView",
normalize_live_active_view(
Map.get(all, @setting_live_active_view, live["activeView"]),
live["activeView"]
)
)
|> Map.put(
"groupDisplayMode",
normalize_display_mode(
Map.get(all, @setting_live_group_display_mode, live["groupDisplayMode"]),
live["groupDisplayMode"]
)
)
|> Map.put(
"groupFixedCode",
String.upcase(String.trim(Map.get(all, @setting_live_group_fixed_code, "")))
)
|> Map.put("showPrelimTie", setting_bool(Map.get(all, @setting_live_show_prelim_tie, "1")))
|> Map.put(
"showPrelimOverall",
setting_bool(Map.get(all, @setting_live_show_prelim_overall, "1"))
)
|> Map.put(
"showFinalGroups",
setting_bool(Map.get(all, @setting_live_show_final_groups, "1"))
)
|> Map.put(
"finalDisplayMode",
normalize_display_mode(
Map.get(all, @setting_live_final_display_mode, live["finalDisplayMode"]),
live["finalDisplayMode"]
)
)
|> Map.put(
"finalFixedGroup",
normalize_final_fixed_group(Map.get(all, @setting_live_final_fixed_group, "1"), 1)
)
|> Map.put("showFinalTie", setting_bool(Map.get(all, @setting_live_show_final_tie, "1")))
|> Map.put("showPodium", setting_bool(Map.get(all, @setting_live_show_podium, "1")))
|> Map.put(
"rotationIntervalSec",
normalize_rotation_interval(Map.get(all, @setting_live_rotation_interval, "5"), 5)
)
|> Map.put(
"rotationPlayerCount",
normalize_rotation_player_count(Map.get(all, @setting_live_rotation_players, "12"), 12)
)
%{
"viewProofInView" => setting_bool(Map.get(all, @setting_view_proof_in_view, "0")),
"liveMode" => live
}
end
def update_settings(%{} = req) do
has_update =
Map.has_key?(req, "viewProofInView") or
Map.has_key?(req, :viewProofInView) or
Map.has_key?(req, "liveMode") or
Map.has_key?(req, :liveMode)
if not has_update do
{:error, "at least one settings field is required"}
else
Repo.transaction(fn ->
req
|> get_value("viewProofInView")
|> maybe_upsert(@setting_view_proof_in_view)
live_patch = get_value(req, "liveMode")
if is_map(live_patch), do: persist_live_patch(live_patch)
end)
|> case do
{:ok, _} -> :ok
{:error, %Ecto.ConstraintError{} = e} -> {:error, Exception.message(e)}
{:error, reason} when is_binary(reason) -> {:error, reason}
{:error, reason} -> {:error, inspect(reason)}
end
end
end
defp persist_live_patch(patch) do
patch
|> get_value("activeView")
|> maybe_upsert(@setting_live_active_view, fn v ->
normalize_live_active_view(v, "group_live")
end)
patch
|> get_value("showGroupLive")
|> maybe_upsert(@setting_live_show_group_live)
patch
|> get_value("groupDisplayMode")
|> maybe_upsert(@setting_live_group_display_mode, fn v ->
normalize_display_mode(v, "rotate")
end)
patch
|> get_value("groupFixedCode")
|> maybe_upsert(@setting_live_group_fixed_code, fn v ->
v |> to_string() |> String.trim() |> String.upcase()
end)
patch
|> get_value("showPrelimTie")
|> maybe_upsert(@setting_live_show_prelim_tie)
patch
|> get_value("showPrelimOverall")
|> maybe_upsert(@setting_live_show_prelim_overall)
patch
|> get_value("showFinalGroups")
|> maybe_upsert(@setting_live_show_final_groups)
patch
|> get_value("finalDisplayMode")
|> maybe_upsert(@setting_live_final_display_mode, fn v ->
normalize_display_mode(v, "rotate")
end)
patch
|> get_value("finalFixedGroup")
|> maybe_upsert(@setting_live_final_fixed_group, fn v -> normalize_final_fixed_group(v, 1) end)
patch
|> get_value("showFinalTie")
|> maybe_upsert(@setting_live_show_final_tie)
patch
|> get_value("showPodium")
|> maybe_upsert(@setting_live_show_podium)
patch
|> get_value("rotationIntervalSec")
|> maybe_upsert(@setting_live_rotation_interval, fn v -> normalize_rotation_interval(v, 5) end)
patch
|> get_value("rotationPlayerCount")
|> maybe_upsert(@setting_live_rotation_players, fn v ->
normalize_rotation_player_count(v, 12)
end)
end
defp maybe_upsert(nil, _key), do: :ok
defp maybe_upsert(value, key) do
normalized = setting_string(value)
Repo.insert!(
%AppSetting{key: key, value: normalized},
on_conflict: [set: [value: normalized, updated_at: DateTime.utc_now()]],
conflict_target: :key
)
end
defp maybe_upsert(nil, _key, _fun), do: :ok
defp maybe_upsert(value, key, normalize_fun) do
normalized = value |> normalize_fun.() |> setting_string()
maybe_upsert(normalized, key)
end
defp get_value(map, key) when is_map(map) do
cond do
Map.has_key?(map, key) -> Map.get(map, key)
Map.has_key?(map, String.to_atom(key)) -> Map.get(map, String.to_atom(key))
true -> nil
end
end
defp setting_bool(value) do
case value |> to_string() |> String.trim() |> String.downcase() do
"1" -> true
"true" -> true
"yes" -> true
"on" -> true
_ -> false
end
end
defp setting_string(v) when is_boolean(v), do: if(v, do: "1", else: "0")
defp setting_string(v) when is_integer(v), do: Integer.to_string(v)
defp setting_string(v), do: v |> to_string()
defp normalize_display_mode(value, fallback) do
case value |> to_string() |> String.trim() |> String.downcase() do
"fixed" -> "fixed"
"rotate" -> "rotate"
_ -> if(String.downcase(to_string(fallback)) == "fixed", do: "fixed", else: "rotate")
end
end
defp normalize_final_fixed_group(value, fallback) do
parsed =
case Integer.parse(to_string(value)) do
{v, _} -> v
_ -> fallback
end
if parsed == 2, do: 2, else: 1
end
defp normalize_rotation_interval(value, fallback) do
parsed =
case Integer.parse(to_string(value)) do
{v, _} -> v
_ -> fallback
end
parsed |> max(3) |> min(30)
end
defp normalize_rotation_player_count(value, fallback) do
parsed =
case Integer.parse(to_string(value)) do
{v, _} -> v
_ -> fallback
end
parsed |> max(3) |> min(40)
end
defp normalize_live_active_view(value, fallback) do
normalized =
value
|> to_string()
|> String.trim()
|> String.downcase()
cond do
MapSet.member?(@allowed_live_views, normalized) -> normalized
MapSet.member?(@allowed_live_views, to_string(fallback)) -> fallback
true -> "group_live"
end
end
end

View File

@@ -0,0 +1,42 @@
defmodule ShootingEventPhx.Domain.Stages do
@moduledoc false
@preliminary_round_stages ["prelim_r1", "prelim_r2", "prelim_r3"]
@final_round_stages ["final_r1", "final_r2"]
@tie_break_stages ["prelim_tiebreak", "final_tiebreak"]
@score_stages @preliminary_round_stages ++ @final_round_stages ++ @tie_break_stages
def preliminary_round_stages, do: @preliminary_round_stages
def final_round_stages, do: @final_round_stages
def tie_break_stages, do: @tie_break_stages
def score_stages, do: @score_stages
def valid_stage?(stage), do: normalize_stage(stage) in @score_stages
def normalize_stage(stage) do
stage
|> to_string()
|> String.trim()
|> String.downcase()
end
def validate_stage(stage) do
normalized = normalize_stage(stage)
if normalized in @score_stages do
{:ok, normalized}
else
{:error, "invalid stage"}
end
end
def resolve_reset_stages(stage) do
case normalize_stage(stage) do
"preliminary" -> {:ok, @preliminary_round_stages}
"final" -> {:ok, @final_round_stages ++ ["final_tiebreak"]}
"prelim_tiebreak" -> {:ok, ["prelim_tiebreak"]}
"final_tiebreak" -> {:ok, ["final_tiebreak"]}
_ -> {:error, "invalid stage"}
end
end
end

View File

@@ -0,0 +1,43 @@
defmodule ShootingEventPhx.Domain.StateService do
@moduledoc false
alias ShootingEventPhx.Domain.{Derived, EventData, Settings}
def read_state(include_all_proofs \\ false) do
players = EventData.list_players()
_ = EventData.ensure_score_rows(players)
settings = Settings.read_settings()
scores = EventData.read_scores(players)
include_score_proofs = include_all_proofs or settings["viewProofInView"]
score_proofs = if include_score_proofs, do: EventData.read_score_proofs(players), else: %{}
derived = Derived.compute(players, scores)
response = %{
"competition" => %{
"titleAr" => "بطولة دويتوايلر للرماية",
"titleEn" => "Datwyler Shooting Event"
},
"players" => Enum.map(players, &player_to_json/1),
"scores" => EventData.score_map_to_json(scores),
"settings" => settings,
"derived" => derived,
"serverTime" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
}
if include_score_proofs do
Map.put(response, "scoreProofs", EventData.proof_map_to_json(score_proofs))
else
response
end
end
defp player_to_json(player) do
%{
"id" => player.id,
"nameAr" => player.name_ar,
"nameEn" => player.name_en,
"groupCode" => player.group_code,
"imageData" => player.image_data
}
end
end

View File

@@ -0,0 +1,15 @@
defmodule ShootingEventPhx.Events do
@moduledoc false
@topic "state_updates"
def topic, do: @topic
def subscribe do
Phoenix.PubSub.subscribe(ShootingEventPhx.PubSub, @topic)
end
def broadcast do
payload = {:state, DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()}
Phoenix.PubSub.broadcast(ShootingEventPhx.PubSub, @topic, payload)
end
end

View File

@@ -0,0 +1,5 @@
defmodule ShootingEventPhx.Repo do
use Ecto.Repo,
otp_app: :shooting_event_phx,
adapter: Ecto.Adapters.SQLite3
end

View File

@@ -0,0 +1,63 @@
defmodule ShootingEventPhxWeb do
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, components, channels, and so on.
This can be used in your application as:
use ShootingEventPhxWeb, :controller
use ShootingEventPhxWeb, :html
The definitions below will be executed for every controller,
component, etc, so keep them short and clean, focused
on imports, uses and aliases.
Do NOT define functions inside the quoted expressions
below. Instead, define additional modules and import
those modules here.
"""
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
def router do
quote do
use Phoenix.Router, helpers: false
# Import common connection and controller functions to use in pipelines
import Plug.Conn
import Phoenix.Controller
end
end
def channel do
quote do
use Phoenix.Channel
end
end
def controller do
quote do
use Phoenix.Controller, formats: [:html, :json]
import Plug.Conn
unquote(verified_routes())
end
end
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: ShootingEventPhxWeb.Endpoint,
router: ShootingEventPhxWeb.Router,
statics: ShootingEventPhxWeb.static_paths()
end
end
@doc """
When used, dispatch to the appropriate controller/live_view/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end

View File

@@ -0,0 +1,221 @@
defmodule ShootingEventPhxWeb.ApiController do
use ShootingEventPhxWeb, :controller
alias ShootingEventPhx.{Auth, Events}
alias ShootingEventPhx.Auth.SessionStore
alias ShootingEventPhx.Domain.{EventData, Settings, Stages, StateService}
alias ShootingEventPhx.AI.Gemini
def health(conn, _params) do
json(conn, %{"status" => "ok"})
end
def state(conn, _params) do
state = StateService.read_state(Auth.admin_request?(conn))
json(conn, state)
end
def admin_state(conn, _params) do
json(conn, StateService.read_state(true))
end
def admin_login(conn, params) do
username = Map.get(params, "username", "")
password = Map.get(params, "password", "")
if Auth.verify_admin(username, password) do
{:ok, token, expires_at} = SessionStore.create_token()
json(conn, %{
"token" => token,
"expiresAt" => DateTime.to_iso8601(expires_at),
"username" => Auth.admin_user()
})
else
error(conn, :unauthorized, "invalid credentials")
end
end
def admin_logout(conn, _params) do
case Auth.bearer_token(conn) do
nil -> :ok
token -> SessionStore.delete_token(token)
end
json(conn, %{"ok" => true})
end
def update_settings(conn, params) do
case Settings.update_settings(params) do
:ok ->
Events.broadcast()
json(conn, StateService.read_state(true))
{:error, message} ->
error(conn, :bad_request, message)
end
end
def create_player(conn, params) do
case EventData.create_player(params) do
:ok ->
Events.broadcast()
conn |> put_status(:created) |> json(StateService.read_state(true))
{:error, :bad_request, message} ->
error(conn, :bad_request, message)
{:error, _, message} ->
error(conn, :internal_server_error, message)
end
end
def update_player(conn, %{"id" => id} = params) do
with {player_id, ""} <- Integer.parse(to_string(id)),
result <- EventData.update_player(player_id, params),
:ok <- result do
Events.broadcast()
json(conn, StateService.read_state(true))
else
:error -> error(conn, :bad_request, "invalid player id")
{:error, :bad_request, message} -> error(conn, :bad_request, message)
{:error, :not_found, message} -> error(conn, :not_found, message)
{:error, _, message} -> error(conn, :internal_server_error, message)
end
end
def delete_player(conn, %{"id" => id}) do
with {player_id, ""} <- Integer.parse(to_string(id)),
:ok <- EventData.delete_player(player_id) do
Events.broadcast()
json(conn, StateService.read_state(true))
else
:error -> error(conn, :bad_request, "invalid player id")
{:error, :not_found, message} -> error(conn, :not_found, message)
{:error, _, message} -> error(conn, :internal_server_error, message)
end
end
def auto_group_players(conn, params) do
groups = Map.get(params, "groups", [])
case EventData.auto_group_players(groups) do
:ok ->
Events.broadcast()
json(conn, StateService.read_state(true))
{:error, :bad_request, message} ->
error(conn, :bad_request, message)
{:error, _, message} ->
error(conn, :internal_server_error, message)
end
end
def update_score(conn, %{"stage" => stage, "id" => id} = params) do
with {:ok, normalized_stage} <- Stages.validate_stage(stage),
{player_id, ""} <- Integer.parse(to_string(id)),
score <- parse_score(Map.get(params, "score")),
:ok <- EventData.update_score(normalized_stage, player_id, score) do
Events.broadcast()
json(conn, StateService.read_state(true))
else
{:error, message} -> error(conn, :bad_request, message)
:error -> error(conn, :bad_request, "invalid player id")
{:invalid_score, message} -> error(conn, :bad_request, message)
{:error, :bad_request, message} -> error(conn, :bad_request, message)
{:error, _, message} -> error(conn, :internal_server_error, message)
end
end
def reset_stage(conn, %{"stage" => stage} = params) do
with {:ok, target_stages} <- Stages.resolve_reset_stages(stage),
reset_proofs <- truthy?(Map.get(params, "resetProofs")),
:ok <- EventData.reset_stage_scores(target_stages, reset_proofs) do
Events.broadcast()
json(conn, StateService.read_state(true))
else
{:error, message} -> error(conn, :bad_request, message)
{:error, _, message} -> error(conn, :internal_server_error, message)
end
end
def update_score_proof(conn, %{"stage" => stage, "id" => id} = params) do
with {:ok, normalized_stage} <- Stages.validate_stage(stage),
{player_id, ""} <- Integer.parse(to_string(id)),
image_data when is_binary(image_data) <- Map.get(params, "imageData"),
:ok <- EventData.update_score_proof(normalized_stage, player_id, image_data) do
Events.broadcast()
json(conn, StateService.read_state(true))
else
{:error, message} -> error(conn, :bad_request, message)
:error -> error(conn, :bad_request, "invalid player id")
nil -> error(conn, :bad_request, "imageData is required")
{:error, :bad_request, message} -> error(conn, :bad_request, message)
{:error, _, message} -> error(conn, :internal_server_error, message)
end
end
def delete_score_proof(conn, %{"stage" => stage, "id" => id}) do
with {:ok, normalized_stage} <- Stages.validate_stage(stage),
{player_id, ""} <- Integer.parse(to_string(id)),
:ok <- EventData.delete_score_proof(normalized_stage, player_id) do
Events.broadcast()
json(conn, StateService.read_state(true))
else
{:error, message} -> error(conn, :bad_request, message)
:error -> error(conn, :bad_request, "invalid player id")
{:error, _, message} -> error(conn, :internal_server_error, message)
end
end
def score_advice(conn, %{"stage" => stage, "id" => id}) do
with {:ok, normalized_stage} <- Stages.validate_stage(stage),
{player_id, ""} <- Integer.parse(to_string(id)),
{:ok, image_data} <- EventData.load_score_proof_image(normalized_stage, player_id),
{:ok, current_score} <- EventData.load_current_score(normalized_stage, player_id),
{:ok, advice} <-
Gemini.generate_score_advice(normalized_stage, player_id, image_data, current_score) do
json(conn, advice)
else
{:error, :not_found} -> error(conn, :bad_request, "no proof image found for this score")
{:error, :service_unavailable, message} -> error(conn, :service_unavailable, message)
{:error, :bad_gateway, message} -> error(conn, :bad_gateway, message)
{:error, message} when is_binary(message) -> error(conn, :bad_request, message)
:error -> error(conn, :bad_request, "invalid player id")
end
end
defp parse_score(value) do
cond do
is_integer(value) ->
value
is_binary(value) ->
case Integer.parse(String.trim(value)) do
{v, ""} -> v
_ -> {:invalid_score, "invalid request body"}
end
true ->
{:invalid_score, "invalid request body"}
end
end
defp truthy?(value) do
case value do
true -> true
1 -> true
"1" -> true
"true" -> true
"yes" -> true
"on" -> true
_ -> false
end
end
defp error(conn, status, message) do
conn
|> put_status(status)
|> json(%{"message" => message})
end
end

View File

@@ -0,0 +1,21 @@
defmodule ShootingEventPhxWeb.ErrorJSON do
@moduledoc """
This module is invoked by your endpoint in case of errors on JSON requests.
See config/config.exs.
"""
# If you want to customize a particular status code,
# you may add your own clauses, such as:
#
# def render("500.json", _assigns) do
# %{errors: %{detail: "Internal Server Error"}}
# end
# By default, Phoenix returns the status message from
# the template name. For example, "404.json" becomes
# "Not Found".
def render(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end
end

View File

@@ -0,0 +1,40 @@
defmodule ShootingEventPhxWeb.EventsController do
use ShootingEventPhxWeb, :controller
alias ShootingEventPhx.Events
def stream(conn, _params) do
conn =
conn
|> put_resp_header("content-type", "text/event-stream")
|> put_resp_header("cache-control", "no-cache, no-transform")
|> put_resp_header("connection", "keep-alive")
|> put_resp_header("x-accel-buffering", "no")
|> send_chunked(200)
now = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
{:ok, conn} = chunk(conn, "event: ready\ndata: {\"ts\":\"#{now}\"}\n\n")
:ok = Events.subscribe()
loop_stream(conn)
end
defp loop_stream(conn) do
receive do
{:state, ts} ->
case chunk(conn, "event: state\ndata: {\"ts\":\"#{ts}\"}\n\n") do
{:ok, conn} -> loop_stream(conn)
{:error, :closed} -> conn
{:error, _} -> conn
end
after
20_000 ->
now_unix = DateTime.utc_now() |> DateTime.to_unix()
case chunk(conn, ": ping #{now_unix}\n\n") do
{:ok, conn} -> loop_stream(conn)
{:error, :closed} -> conn
{:error, _} -> conn
end
end
end
end

View File

@@ -0,0 +1,57 @@
defmodule ShootingEventPhxWeb.FrontendController do
use ShootingEventPhxWeb, :controller
def serve(conn, %{"path" => segments}) do
request_path =
segments
|> List.wrap()
|> Enum.join("/")
|> String.trim_leading("/")
if String.starts_with?(request_path, "api") do
send_resp(conn, 404, "Not Found")
else
web_dir = web_dir()
requested = if request_path == "", do: "index.html", else: request_path
requested_file = safe_file_path(web_dir, requested)
cond do
requested_file != nil and File.regular?(requested_file) ->
send_static_file(conn, requested_file)
File.regular?(Path.join(web_dir, "index.html")) ->
send_static_file(conn, Path.join(web_dir, "index.html"))
true ->
send_resp(conn, 404, "frontend build not found. run make build")
end
end
end
defp web_dir do
System.get_env("WEB_DIR") || Path.expand("../frontend/dist", File.cwd!())
end
defp safe_file_path(web_dir, request_path) do
cleaned = request_path |> Path.expand(web_dir)
root = Path.expand(web_dir)
if String.starts_with?(cleaned, root) do
cleaned
else
nil
end
end
defp send_static_file(conn, file_path) do
content_type =
case MIME.from_path(file_path) do
"" -> "application/octet-stream"
type -> type
end
conn
|> put_resp_content_type(content_type)
|> send_file(200, file_path)
end
end

View File

@@ -0,0 +1,50 @@
defmodule ShootingEventPhxWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :shooting_event_phx
# The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it.
@session_options [
store: :cookie,
key: "_shooting_event_phx_key",
signing_salt: "RrxJbij4",
same_site: "Lax"
]
# socket "/live", Phoenix.LiveView.Socket,
# websocket: [connect_info: [session: @session_options]],
# longpoll: [connect_info: [session: @session_options]]
# Serve at "/" the static files from "priv/static" directory.
#
# When code reloading is disabled (e.g., in production),
# the `gzip` option is enabled to serve compressed
# static files generated by running `phx.digest`.
plug Plug.Static,
at: "/",
from: :shooting_event_phx,
gzip: not code_reloading?,
only: ShootingEventPhxWeb.static_paths(),
raise_on_missing_only: code_reloading?
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
plug Phoenix.CodeReloader
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :shooting_event_phx
end
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug ShootingEventPhxWeb.Plugs.CORS
plug Plug.Session, @session_options
plug ShootingEventPhxWeb.Router
end

View File

@@ -0,0 +1,18 @@
defmodule ShootingEventPhxWeb.Plugs.AdminOnly do
@moduledoc false
import Plug.Conn
alias ShootingEventPhx.Auth
def init(opts), do: opts
def call(conn, _opts) do
if Auth.admin_request?(conn) do
conn
else
conn
|> put_status(:unauthorized)
|> Phoenix.Controller.json(%{"message" => "invalid or expired admin token"})
|> halt()
end
end
end

View File

@@ -0,0 +1,20 @@
defmodule ShootingEventPhxWeb.Plugs.CORS do
@moduledoc false
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
conn =
conn
|> put_resp_header("access-control-allow-origin", "*")
|> put_resp_header("access-control-allow-methods", "GET,POST,PUT,DELETE,OPTIONS")
|> put_resp_header("access-control-allow-headers", "content-type,authorization")
if conn.method == "OPTIONS" do
conn |> send_resp(204, "") |> halt()
else
conn
end
end
end

View File

@@ -0,0 +1,49 @@
defmodule ShootingEventPhxWeb.Router do
use ShootingEventPhxWeb, :router
pipeline :api_json do
plug :accepts, ["json"]
end
pipeline :frontend do
end
pipeline :admin_api do
plug ShootingEventPhxWeb.Plugs.AdminOnly
end
scope "/api", ShootingEventPhxWeb do
get "/events", EventsController, :stream
end
scope "/api", ShootingEventPhxWeb do
pipe_through :api_json
get "/health", ApiController, :health
get "/state", ApiController, :state
post "/admin/login", ApiController, :admin_login
scope "/admin" do
pipe_through :admin_api
post "/logout", ApiController, :admin_logout
get "/state", ApiController, :admin_state
put "/settings", ApiController, :update_settings
post "/players", ApiController, :create_player
post "/players/auto-group", ApiController, :auto_group_players
put "/players/:id", ApiController, :update_player
delete "/players/:id", ApiController, :delete_player
put "/scores/:stage/:id", ApiController, :update_score
put "/scores/:stage/:id/proof", ApiController, :update_score_proof
delete "/scores/:stage/:id/proof", ApiController, :delete_score_proof
post "/scores/:stage/:id/advice", ApiController, :score_advice
post "/scores/:stage/reset", ApiController, :reset_stage
end
end
scope "/", ShootingEventPhxWeb do
pipe_through :frontend
match :*, "/*path", FrontendController, :serve
end
end

View File

@@ -0,0 +1,93 @@
defmodule ShootingEventPhxWeb.Telemetry do
use Supervisor
import Telemetry.Metrics
def start_link(arg) do
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
end
@impl true
def init(_arg) do
children = [
# Telemetry poller will execute the given period measurements
# every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
# Add reporters as children of your supervision tree.
# {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
]
Supervisor.init(children, strategy: :one_for_one)
end
def metrics do
[
# Phoenix Metrics
summary("phoenix.endpoint.start.system_time",
unit: {:native, :millisecond}
),
summary("phoenix.endpoint.stop.duration",
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.start.system_time",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.exception.duration",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.stop.duration",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.socket_connected.duration",
unit: {:native, :millisecond}
),
sum("phoenix.socket_drain.count"),
summary("phoenix.channel_joined.duration",
unit: {:native, :millisecond}
),
summary("phoenix.channel_handled_in.duration",
tags: [:event],
unit: {:native, :millisecond}
),
# Database Metrics
summary("shooting_event_phx.repo.query.total_time",
unit: {:native, :millisecond},
description: "The sum of the other measurements"
),
summary("shooting_event_phx.repo.query.decode_time",
unit: {:native, :millisecond},
description: "The time spent decoding the data received from the database"
),
summary("shooting_event_phx.repo.query.query_time",
unit: {:native, :millisecond},
description: "The time spent executing the query"
),
summary("shooting_event_phx.repo.query.queue_time",
unit: {:native, :millisecond},
description: "The time spent waiting for a database connection"
),
summary("shooting_event_phx.repo.query.idle_time",
unit: {:native, :millisecond},
description:
"The time the connection spent waiting before being checked out for the query"
),
# VM Metrics
summary("vm.memory.total", unit: {:byte, :kilobyte}),
summary("vm.total_run_queue_lengths.total"),
summary("vm.total_run_queue_lengths.cpu"),
summary("vm.total_run_queue_lengths.io")
]
end
defp periodic_measurements do
[
# A module, function and arguments to be invoked periodically.
# This function must call :telemetry.execute/3 and a metric must be added above.
# {ShootingEventPhxWeb, :count_users, []}
]
end
end

70
phoenix/mix.exs Normal file
View File

@@ -0,0 +1,70 @@
defmodule ShootingEventPhx.MixProject do
use Mix.Project
def project do
[
app: :shooting_event_phx,
version: "0.1.0",
elixir: "~> 1.15",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps(),
listeners: [Phoenix.CodeReloader]
]
end
# Configuration for the OTP application.
#
# Type `mix help compile.app` for more information.
def application do
[
mod: {ShootingEventPhx.Application, []},
extra_applications: [:logger, :runtime_tools]
]
end
def cli do
[
preferred_envs: [precommit: :test]
]
end
# Specifies which paths to compile per environment.
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
# Specifies your project dependencies.
#
# Type `mix help deps` for examples and options.
defp deps do
[
{:phoenix, "~> 1.8.5"},
{:phoenix_ecto, "~> 4.5"},
{:ecto_sql, "~> 3.13"},
{:ecto_sqlite3, ">= 0.0.0"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
{:jason, "~> 1.2"},
{:req, "~> 0.5"},
{:dns_cluster, "~> 0.2.0"},
{:bandit, "~> 1.5"}
]
end
# Aliases are shortcuts or tasks specific to the current project.
# For example, to install project dependencies and perform other setup tasks, run:
#
# $ mix setup
#
# See the documentation for `Mix` for more info on aliases.
defp aliases do
[
setup: ["deps.get", "ecto.setup"],
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"]
]
end
end

32
phoenix/mix.lock Normal file
View File

@@ -0,0 +1,32 @@
%{
"bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"},
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
}

View File

@@ -0,0 +1,4 @@
[
import_deps: [:ecto_sql],
inputs: ["*.exs"]
]

View File

@@ -0,0 +1,92 @@
defmodule ShootingEventPhx.Repo.Migrations.InitEventSchema do
use Ecto.Migration
def up do
execute("""
CREATE TABLE IF NOT EXISTS players (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name_ar TEXT NOT NULL,
name_en TEXT NOT NULL,
group_code TEXT NOT NULL DEFAULT '',
image_data TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
""")
execute("""
CREATE TABLE IF NOT EXISTS scores (
stage TEXT NOT NULL,
player_id INTEGER NOT NULL,
score INTEGER NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(stage, player_id),
FOREIGN KEY(player_id) REFERENCES players(id) ON DELETE CASCADE
);
""")
execute("""
CREATE TABLE IF NOT EXISTS score_attachments (
stage TEXT NOT NULL,
player_id INTEGER NOT NULL,
image_data TEXT NOT NULL DEFAULT '',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(stage, player_id),
FOREIGN KEY(player_id) REFERENCES players(id) ON DELETE CASCADE
);
""")
execute("""
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
""")
execute("""
INSERT OR IGNORE INTO app_settings(key, value) VALUES
('view_proof_in_view', '0'),
('live_active_view', 'group_live'),
('live_show_group_live', '1'),
('live_group_display_mode', 'rotate'),
('live_group_fixed_code', ''),
('live_show_prelim_tie', '1'),
('live_show_prelim_overall', '1'),
('live_show_final_groups', '1'),
('live_final_display_mode', 'rotate'),
('live_final_fixed_group', '1'),
('live_show_final_tie', '1'),
('live_show_podium', '1'),
('live_rotation_interval_sec', '5'),
('live_rotation_player_count', '12');
""")
execute("""
INSERT OR IGNORE INTO scores(stage, player_id, score)
SELECT 'prelim_r1', player_id, score FROM scores WHERE stage = 'preliminary';
""")
execute("""
INSERT OR IGNORE INTO scores(stage, player_id, score)
SELECT 'final_r1', player_id, score FROM scores WHERE stage = 'final';
""")
execute("""
INSERT OR IGNORE INTO score_attachments(stage, player_id, image_data)
SELECT 'prelim_r1', player_id, image_data FROM score_attachments WHERE stage = 'preliminary';
""")
execute("""
INSERT OR IGNORE INTO score_attachments(stage, player_id, image_data)
SELECT 'final_r1', player_id, image_data FROM score_attachments WHERE stage = 'final';
""")
end
def down do
execute("DROP TABLE IF EXISTS score_attachments;")
execute("DROP TABLE IF EXISTS scores;")
execute("DROP TABLE IF EXISTS app_settings;")
execute("DROP TABLE IF EXISTS players;")
end
end

View File

@@ -0,0 +1,11 @@
# Script for populating the database. You can run it as:
#
# mix run priv/repo/seeds.exs
#
# Inside the script, you can read and write to any of your
# repositories directly:
#
# ShootingEventPhx.Repo.insert!(%ShootingEventPhx.SomeSchema{})
#
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

View File

@@ -0,0 +1,5 @@
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
#
# To ban all spiders from the entire site uncomment the next two lines:
# User-agent: *
# Disallow: /

View File

@@ -0,0 +1,14 @@
defmodule ShootingEventPhxWeb.ErrorJSONTest do
use ShootingEventPhxWeb.ConnCase, async: true
test "renders 404" do
assert ShootingEventPhxWeb.ErrorJSON.render("404.json", %{}) == %{
errors: %{detail: "Not Found"}
}
end
test "renders 500" do
assert ShootingEventPhxWeb.ErrorJSON.render("500.json", %{}) ==
%{errors: %{detail: "Internal Server Error"}}
end
end

View File

@@ -0,0 +1,38 @@
defmodule ShootingEventPhxWeb.ConnCase do
@moduledoc """
This module defines the test case to be used by
tests that require setting up a connection.
Such tests rely on `Phoenix.ConnTest` and also
import other functionality to make it easier
to build common data structures and query the data layer.
Finally, if the test case interacts with the database,
we enable the SQL sandbox, so changes done to the database
are reverted at the end of every test. If you are using
PostgreSQL, you can even run database tests asynchronously
by setting `use ShootingEventPhxWeb.ConnCase, async: true`, although
this option is not recommended for other databases.
"""
use ExUnit.CaseTemplate
using do
quote do
# The default endpoint for testing
@endpoint ShootingEventPhxWeb.Endpoint
use ShootingEventPhxWeb, :verified_routes
# Import conveniences for testing with connections
import Plug.Conn
import Phoenix.ConnTest
import ShootingEventPhxWeb.ConnCase
end
end
setup tags do
ShootingEventPhx.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
end

View File

@@ -0,0 +1,58 @@
defmodule ShootingEventPhx.DataCase do
@moduledoc """
This module defines the setup for tests requiring
access to the application's data layer.
You may define functions here to be used as helpers in
your tests.
Finally, if the test case interacts with the database,
we enable the SQL sandbox, so changes done to the database
are reverted at the end of every test. If you are using
PostgreSQL, you can even run database tests asynchronously
by setting `use ShootingEventPhx.DataCase, async: true`, although
this option is not recommended for other databases.
"""
use ExUnit.CaseTemplate
using do
quote do
alias ShootingEventPhx.Repo
import Ecto
import Ecto.Changeset
import Ecto.Query
import ShootingEventPhx.DataCase
end
end
setup tags do
ShootingEventPhx.DataCase.setup_sandbox(tags)
:ok
end
@doc """
Sets up the sandbox based on the test tags.
"""
def setup_sandbox(tags) do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(ShootingEventPhx.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
end
@doc """
A helper that transforms changeset errors into a map of messages.
assert {:error, changeset} = Accounts.create_user(%{password: "short"})
assert "password is too short" in errors_on(changeset).password
assert %{password: ["password is too short"]} = errors_on(changeset)
"""
def errors_on(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
end
end

View File

@@ -0,0 +1,2 @@
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(ShootingEventPhx.Repo, :manual)