commit 802906f9d483c6ecc8cc841e327dfd04ec541265 Author: Jan Meinl Date: Sat May 16 20:39:27 2026 +0200 Add basic web app and API integration diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0978c43 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/penalty-tracker +/penaltytracker.db +/penaltytracker.db-shm +/penaltytracker.db-wal diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..9a897b6 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/PenaltyTracker.iml b/.idea/PenaltyTracker.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/PenaltyTracker.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/csv-editor.xml b/.idea/csv-editor.xml new file mode 100644 index 0000000..e34998d --- /dev/null +++ b/.idea/csv-editor.xml @@ -0,0 +1,65 @@ + + + + + + \ No newline at end of file diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..644cdf0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..fbfed63 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4891dd1 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# Penalty Tracker + +A small, self-contained system for tracking competition penalties (e.g. for hot-air balloon competitions). + +## Features + +- **System Admin** can create competitions and manage all users. +- **Chief Scorer** (per competition) manages pilots, members, settings, and can export penalties as CSV. Chief Scorers can promote others to Chief Scorer or Scorer. +- **Scorer** (per competition) adds penalties. Scorers can only edit/delete their own entries unless the competition setting "allow any scorer to edit" is on. +- Pilots can be added manually or imported from CSV (`number,last_name,first_name,country,balloon_id`). +- Rule catalogues per language are loaded from `rules/*.csv`. The penalty form lets you search rules by number or text and shows the suggested penalty plus repeat-behaviour (same / doubled / escalates). +- All open clients receive **live updates** for a competition via WebSocket — works for 30+ concurrent users on a single SQLite process. +- The penalty table is **client-side sortable** by every column and updates **in-place without flicker** when new rows arrive. +- Multi-lingual UI: English, German, Polish, Russian, French, Spanish, Portuguese (set per user in user settings). +- Clean black / white design with `#2b6cb0` accent, max border-radius `0.375rem`. + +## Build & run + +```bash +go build -o penaltytracker . +./penaltytracker +``` + +Then open . + +**Default login (created automatically on first start):** + +``` +username: admin +password: admin +``` + +Change the password immediately under the user menu. + +## Configuration (env vars) + +| Variable | Default | Description | +|-----------------|----------------------|--------------------------------------------------------------------------| +| `ADDR` | `:8080` | HTTP listen address | +| `DB_PATH` | `penaltytracker.db` | SQLite database path | +| `RULES_DIR` | `rules` | Directory containing per-language rule CSV files | +| `WEB_DIR` | `web` | Directory containing the frontend static files | +| `CORS_ORIGINS` | *(empty)* | Comma-separated allowed frontend origins (e.g. `http://127.0.0.1:36823,https://app.example.com`). When set, CORS headers are added. Use `*` to allow any origin. | +| `COOKIE_CROSS_SITE` | *(empty)* | Set to `true` only when the frontend runs on a different *site* (different registrable domain) than the API and you need cross-site cookies. Switches the session cookie to `SameSite=None; Secure` — requires HTTPS on both sides. Leave unset for same-host different-port setups (e.g. `127.0.0.1:36823 → 127.0.0.1:8080`); `SameSite=Lax` already works there. | + +### Frontend backend URL + +If you host the frontend on a different origin than the API, open the login screen and fill in **Backend URL** (e.g. `http://192.168.0.10:8080` or `https://api.example.com`). It is stored in `localStorage` (`api_base`). Leave it empty to use the same origin as the page. You can also pre-set it by editing `web/config.js` (`DEFAULT_API_BASE`). + +Cross-origin cookies require **HTTPS on both sides** (so the `Secure` cookie flag is accepted). For LAN testing on plain HTTP, host the frontend and backend on the same origin (the default). + +## Rules CSV format + +`rules/.csv` — one file per language code (`en`, `de`, `pl`, `ru`, `fr`, `es`, `pt`). + +```csv +rule_number,rule_text,suggested_penalty,escalation_mode +R1.1,Late at briefing,Warning,escalate:Warning|50 CP|100 CP|DSQ +R3.1,Unsafe flying,200 CP,doubled +R4.2,Incorrect declaration,No Result,same +``` + +`escalation_mode`: +- `same` — penalty stays the same on repeat. +- `doubled` — penalty doubles each time. +- `escalate:tier1|tier2|tier3|...` — penalty climbs through the listed tiers. + +To reload rules without restart, POST `/api/rules/reload` as a system admin. + +## Pilot import CSV format + +```csv +number,last_name,first_name,country,balloon_id +1,Doe,John,DE,D-OABC +2,Müller,Anna,DE, +``` + +Header row is optional and auto-detected. + +## Notes + +- Storage is a single SQLite file in WAL mode — easy to back up by copying the `.db` file (along with `-wal` / `-shm` if present) while the server is briefly stopped. +- The frontend is pure HTML/CSS/JS, no build step. +- The Go binary embeds nothing — it serves `./web` and `./rules` from the working directory. diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..b76539b --- /dev/null +++ b/auth.go @@ -0,0 +1,256 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +type ctxKey string + +const userCtxKey ctxKey = "user" + +const sessionCookie = "pt_session" +const sessionDuration = 24 * time.Hour * 14 + +func registerAuthRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /api/login", handleLogin) + mux.HandleFunc("POST /api/logout", handleLogout) + mux.HandleFunc("GET /api/me", requireAuth(handleMe)) + mux.HandleFunc("PATCH /api/me", requireAuth(handleUpdateMe)) +} + +func ensureDefaultAdmin() error { + var count int + if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil { + return err + } + if count > 0 { + return nil + } + hash, err := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost) + if err != nil { + return err + } + _, err = db.Exec( + "INSERT INTO users(username,password_hash,display_name,language,is_system_admin) VALUES(?,?,?,?,1)", + "admin", string(hash), "System Admin", "en", + ) + return err +} + +func newToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func handleLogin(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body") + return + } + req.Username = strings.TrimSpace(req.Username) + if req.Username == "" || req.Password == "" { + writeError(w, http.StatusBadRequest, "missing_credentials") + return + } + var id int64 + var hash string + err := db.QueryRow("SELECT id,password_hash FROM users WHERE username=?", req.Username).Scan(&id, &hash) + if err != nil { + writeError(w, http.StatusUnauthorized, "invalid_credentials") + return + } + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)); err != nil { + writeError(w, http.StatusUnauthorized, "invalid_credentials") + return + } + token, err := newToken() + if err != nil { + writeError(w, http.StatusInternalServerError, "token_error") + return + } + expires := time.Now().Add(sessionDuration) + if _, err := db.Exec("INSERT INTO sessions(token,user_id,expires_at) VALUES(?,?,?)", token, id, expires.Format(time.RFC3339)); err != nil { + writeError(w, http.StatusInternalServerError, "session_error") + return + } + cookie := &http.Cookie{ + Name: sessionCookie, + Value: token, + Path: "/", + Expires: expires, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + } + if crossSiteCookies { + cookie.SameSite = http.SameSiteNoneMode + cookie.Secure = true + } + http.SetCookie(w, cookie) + user, _ := loadUser(id) + writeJSON(w, http.StatusOK, user) +} + +func handleLogout(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(sessionCookie) + if err == nil { + db.Exec("DELETE FROM sessions WHERE token=?", c.Value) + } + clear := &http.Cookie{ + Name: sessionCookie, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + } + if crossSiteCookies { + clear.SameSite = http.SameSiteNoneMode + clear.Secure = true + } + http.SetCookie(w, clear) + w.WriteHeader(http.StatusNoContent) +} + +func handleMe(w http.ResponseWriter, r *http.Request) { + u := userFromCtx(r) + writeJSON(w, http.StatusOK, u) +} + +func handleUpdateMe(w http.ResponseWriter, r *http.Request) { + u := userFromCtx(r) + var req struct { + Username *string `json:"username"` + Language *string `json:"language"` + DisplayName *string `json:"display_name"` + Password *string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body") + return + } + if req.Username != nil { + newName := strings.TrimSpace(*req.Username) + if newName == "" { + writeError(w, http.StatusBadRequest, "missing_username") + return + } + if _, err := db.Exec("UPDATE users SET username=? WHERE id=?", newName, u.ID); err != nil { + writeError(w, http.StatusConflict, "username_taken") + return + } + } + if req.Language != nil { + if _, err := db.Exec("UPDATE users SET language=? WHERE id=?", *req.Language, u.ID); err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + } + if req.DisplayName != nil { + if _, err := db.Exec("UPDATE users SET display_name=? WHERE id=?", *req.DisplayName, u.ID); err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + } + if req.Password != nil && *req.Password != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) + if err != nil { + writeError(w, http.StatusInternalServerError, "hash_error") + return + } + if _, err := db.Exec("UPDATE users SET password_hash=? WHERE id=?", string(hash), u.ID); err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + } + user, _ := loadUser(u.ID) + writeJSON(w, http.StatusOK, user) +} + +func loadUser(id int64) (*User, error) { + u := &User{} + var admin int + err := db.QueryRow("SELECT id,username,display_name,language,is_system_admin FROM users WHERE id=?", id). + Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin) + if err != nil { + return nil, err + } + u.IsSystemAdmin = admin == 1 + return u, nil +} + +func authUser(r *http.Request) (*User, error) { + c, err := r.Cookie(sessionCookie) + if err != nil { + return nil, errors.New("no_session") + } + var userID int64 + var expiresAt string + err = db.QueryRow("SELECT user_id, expires_at FROM sessions WHERE token=?", c.Value).Scan(&userID, &expiresAt) + if err != nil { + return nil, errors.New("invalid_session") + } + if t, err := time.Parse(time.RFC3339, expiresAt); err == nil && time.Now().After(t) { + db.Exec("DELETE FROM sessions WHERE token=?", c.Value) + return nil, errors.New("expired") + } + return loadUser(userID) +} + +func requireAuth(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + u, err := authUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + ctx := context.WithValue(r.Context(), userCtxKey, u) + h.ServeHTTP(w, r.WithContext(ctx)) + } +} + +func requireAdmin(h http.HandlerFunc) http.HandlerFunc { + return requireAuth(func(w http.ResponseWriter, r *http.Request) { + u := userFromCtx(r) + if !u.IsSystemAdmin { + writeError(w, http.StatusForbidden, "forbidden") + return + } + h.ServeHTTP(w, r) + }) +} + +func userFromCtx(r *http.Request) *User { + v := r.Context().Value(userCtxKey) + if v == nil { + return nil + } + return v.(*User) +} + +func writeJSON(w http.ResponseWriter, code int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + if body != nil { + _ = json.NewEncoder(w).Encode(body) + } +} + +func writeError(w http.ResponseWriter, code int, msg string) { + writeJSON(w, code, map[string]string{"error": msg}) +} diff --git a/competitions.go b/competitions.go new file mode 100644 index 0000000..2c995f5 --- /dev/null +++ b/competitions.go @@ -0,0 +1,248 @@ +package main + +import ( + "database/sql" + "encoding/json" + "net/http" + "strconv" +) + +func registerCompetitionRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/competitions", requireAuth(handleListCompetitions)) + mux.HandleFunc("POST /api/competitions", requireAdmin(handleCreateCompetition)) + mux.HandleFunc("GET /api/competitions/{id}", requireAuth(handleGetCompetition)) + mux.HandleFunc("PATCH /api/competitions/{id}", requireAuth(handleUpdateCompetition)) + mux.HandleFunc("DELETE /api/competitions/{id}", requireAdmin(handleDeleteCompetition)) + mux.HandleFunc("GET /api/competitions/{id}/members", requireAuth(handleListMembers)) + mux.HandleFunc("POST /api/competitions/{id}/members", requireAuth(handleAddMember)) + mux.HandleFunc("DELETE /api/competitions/{id}/members/{uid}", requireAuth(handleRemoveMember)) +} + +func userRole(competitionID, userID int64) (string, error) { + var role string + err := db.QueryRow("SELECT role FROM competition_users WHERE competition_id=? AND user_id=?", competitionID, userID).Scan(&role) + if err == sql.ErrNoRows { + return "", nil + } + return role, err +} + +func canAccessCompetition(u *User, competitionID int64) (string, bool) { + if u.IsSystemAdmin { + return "system_admin", true + } + role, err := userRole(competitionID, u.ID) + if err != nil || role == "" { + return "", false + } + return role, true +} + +func handleListCompetitions(w http.ResponseWriter, r *http.Request) { + u := userFromCtx(r) + var rows *sql.Rows + var err error + if u.IsSystemAdmin { + rows, err = db.Query("SELECT id,name,allow_any_scorer_edit,created_at FROM competitions ORDER BY created_at DESC") + } else { + rows, err = db.Query(`SELECT c.id,c.name,c.allow_any_scorer_edit,c.created_at,cu.role + FROM competitions c JOIN competition_users cu ON cu.competition_id=c.id + WHERE cu.user_id=? ORDER BY c.created_at DESC`, u.ID) + } + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + defer rows.Close() + out := []Competition{} + for rows.Next() { + var c Competition + var allow int + if u.IsSystemAdmin { + rows.Scan(&c.ID, &c.Name, &allow, &c.CreatedAt) + c.Role = "system_admin" + } else { + rows.Scan(&c.ID, &c.Name, &allow, &c.CreatedAt, &c.Role) + } + c.AllowAnyScorerEdit = allow == 1 + out = append(out, c) + } + writeJSON(w, http.StatusOK, out) +} + +func handleCreateCompetition(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + AllowAnyScorerEdit bool `json:"allow_any_scorer_edit"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body") + return + } + if req.Name == "" { + writeError(w, http.StatusBadRequest, "missing_name") + return + } + allow := 0 + if req.AllowAnyScorerEdit { + allow = 1 + } + res, err := db.Exec("INSERT INTO competitions(name,allow_any_scorer_edit) VALUES(?,?)", req.Name, allow) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + id, _ := res.LastInsertId() + c := Competition{ID: id, Name: req.Name, AllowAnyScorerEdit: req.AllowAnyScorerEdit} + writeJSON(w, http.StatusCreated, c) +} + +func handleGetCompetition(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + u := userFromCtx(r) + role, ok := canAccessCompetition(u, id) + if !ok { + writeError(w, http.StatusForbidden, "forbidden") + return + } + var c Competition + var allow int + err = db.QueryRow("SELECT id,name,allow_any_scorer_edit,created_at FROM competitions WHERE id=?", id). + Scan(&c.ID, &c.Name, &allow, &c.CreatedAt) + if err != nil { + writeError(w, http.StatusNotFound, "not_found") + return + } + c.AllowAnyScorerEdit = allow == 1 + c.Role = role + writeJSON(w, http.StatusOK, c) +} + +func handleUpdateCompetition(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + u := userFromCtx(r) + role, ok := canAccessCompetition(u, id) + if !ok || (role != "system_admin" && role != "chief_scorer") { + writeError(w, http.StatusForbidden, "forbidden") + return + } + var req struct { + Name *string `json:"name"` + AllowAnyScorerEdit *bool `json:"allow_any_scorer_edit"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body") + return + } + if req.Name != nil { + db.Exec("UPDATE competitions SET name=? WHERE id=?", *req.Name, id) + } + if req.AllowAnyScorerEdit != nil { + v := 0 + if *req.AllowAnyScorerEdit { + v = 1 + } + db.Exec("UPDATE competitions SET allow_any_scorer_edit=? WHERE id=?", v, id) + } + hub.broadcast(id, "competition_updated", nil) + w.WriteHeader(http.StatusNoContent) +} + +func handleDeleteCompetition(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + db.Exec("DELETE FROM competitions WHERE id=?", id) + w.WriteHeader(http.StatusNoContent) +} + +func handleListMembers(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + u := userFromCtx(r) + if _, ok := canAccessCompetition(u, id); !ok { + writeError(w, http.StatusForbidden, "forbidden") + return + } + rows, err := db.Query(`SELECT u.id,u.username,u.display_name,cu.role + FROM competition_users cu JOIN users u ON u.id=cu.user_id + WHERE cu.competition_id=? ORDER BY cu.role,u.username`, id) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + defer rows.Close() + out := []CompetitionUser{} + for rows.Next() { + var m CompetitionUser + rows.Scan(&m.UserID, &m.Username, &m.DisplayName, &m.Role) + out = append(out, m) + } + writeJSON(w, http.StatusOK, out) +} + +func handleAddMember(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + u := userFromCtx(r) + role, ok := canAccessCompetition(u, id) + if !ok || (role != "system_admin" && role != "chief_scorer") { + writeError(w, http.StatusForbidden, "forbidden") + return + } + var req struct { + UserID int64 `json:"user_id"` + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body") + return + } + if req.Role != "chief_scorer" && req.Role != "scorer" { + writeError(w, http.StatusBadRequest, "invalid_role") + return + } + _, err = db.Exec("INSERT OR REPLACE INTO competition_users(competition_id,user_id,role) VALUES(?,?,?)", id, req.UserID, req.Role) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func handleRemoveMember(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + uid, err := strconv.ParseInt(r.PathValue("uid"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + u := userFromCtx(r) + role, ok := canAccessCompetition(u, id) + if !ok || (role != "system_admin" && role != "chief_scorer") { + writeError(w, http.StatusForbidden, "forbidden") + return + } + db.Exec("DELETE FROM competition_users WHERE competition_id=? AND user_id=?", id, uid) + w.WriteHeader(http.StatusNoContent) +} diff --git a/db.go b/db.go new file mode 100644 index 0000000..332d67b --- /dev/null +++ b/db.go @@ -0,0 +1,92 @@ +package main + +import ( + "database/sql" + + _ "modernc.org/sqlite" +) + +var db *sql.DB + +func openDB(path string) error { + d, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)") + if err != nil { + return err + } + d.SetMaxOpenConns(1) + if err := d.Ping(); err != nil { + return err + } + db = d + return nil +} + +func migrate() error { + stmts := []string{ + `CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + language TEXT NOT NULL DEFAULT 'en', + is_system_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`, + `CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + expires_at TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS competitions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + allow_any_scorer_edit INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`, + `CREATE TABLE IF NOT EXISTS competition_users ( + competition_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + role TEXT NOT NULL, + PRIMARY KEY (competition_id, user_id), + FOREIGN KEY(competition_id) REFERENCES competitions(id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS pilots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + competition_id INTEGER NOT NULL, + number TEXT NOT NULL, + last_name TEXT NOT NULL, + first_name TEXT NOT NULL, + country TEXT NOT NULL DEFAULT '', + balloon_id TEXT NOT NULL DEFAULT '', + UNIQUE(competition_id, number), + FOREIGN KEY(competition_id) REFERENCES competitions(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS penalties ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + competition_id INTEGER NOT NULL, + flight TEXT NOT NULL DEFAULT '', + date TEXT NOT NULL DEFAULT '', + pilot_number TEXT NOT NULL DEFAULT '', + rule_number TEXT NOT NULL DEFAULT '', + task TEXT NOT NULL DEFAULT '', + penalties_text TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + created_by INTEGER NOT NULL, + transferred INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(competition_id) REFERENCES competitions(id) ON DELETE CASCADE, + FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE RESTRICT + )`, + `CREATE INDEX IF NOT EXISTS idx_penalties_competition ON penalties(competition_id)`, + `CREATE INDEX IF NOT EXISTS idx_pilots_competition ON pilots(competition_id)`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + return err + } + } + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a710bbb --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module penaltytracker + +go 1.25.0 + +require ( + github.com/gorilla/websocket v1.5.3 + golang.org/x/crypto v0.51.0 + modernc.org/sqlite v1.50.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.44.0 // indirect + modernc.org/gc/v3 v3.1.3 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/strutil v1.2.1 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..90f0b62 --- /dev/null +++ b/go.sum @@ -0,0 +1,34 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= +modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= +modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= +modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..22af29a --- /dev/null +++ b/main.go @@ -0,0 +1,213 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" +) + +type Config struct { + Addr string `json:"addr"` + DBPath string `json:"db_path"` + RulesDir string `json:"rules_dir"` + CORSOrigins []string `json:"cors_origins"` + CrossSiteCookies bool `json:"cross_site_cookies"` +} + +var corsOrigins []string +var crossSiteCookies bool + +func defaultConfig() *Config { + return &Config{ + Addr: ":8080", + DBPath: "penaltytracker.db", + RulesDir: "rules", + CORSOrigins: []string{}, + CrossSiteCookies: false, + } +} + +func writeConfig(path string, cfg *Config) error { + if dir := filepath.Dir(path); dir != "" { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +func loadConfig(path string) (*Config, error) { + cfg := defaultConfig() + + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + if err := writeConfig(path, cfg); err != nil { + return nil, err + } + log.Printf("config file created at %s with defaults", path) + return cfg, nil + } + return nil, err + } + + if err := json.Unmarshal(data, cfg); err != nil { + return nil, err + } + if cfg.Addr == "" { + cfg.Addr = ":8080" + } + if cfg.DBPath == "" { + cfg.DBPath = "penaltytracker.db" + } + if cfg.RulesDir == "" { + cfg.RulesDir = "rules" + } + for i, o := range cfg.CORSOrigins { + cfg.CORSOrigins[i] = strings.TrimSpace(o) + } + return cfg, nil +} + +func ensureDir(dir string) error { + if dir == "" || dir == "." { + return nil + } + return os.MkdirAll(dir, 0o755) +} + +func ensureDirectories(cfg *Config) error { + if err := ensureDir(cfg.RulesDir); err != nil { + return err + } + if dbDir := filepath.Dir(cfg.DBPath); dbDir != "" { + if err := ensureDir(dbDir); err != nil { + return err + } + } + return nil +} + +func main() { + configPath := flag.String("config", "config.json", "path to config file") + flag.Parse() + + cfg, err := loadConfig(*configPath) + if err != nil { + log.Fatalf("config load: %v", err) + } + + if err := ensureDirectories(cfg); err != nil { + log.Fatalf("ensure directories: %v", err) + } + + corsOrigins = cfg.CORSOrigins + crossSiteCookies = cfg.CrossSiteCookies + + if err := openDB(cfg.DBPath); err != nil { + log.Fatalf("db open: %v", err) + } + if err := migrate(); err != nil { + log.Fatalf("migrate: %v", err) + } + if err := ensureDefaultAdmin(); err != nil { + log.Fatalf("default admin: %v", err) + } + if err := loadRules(cfg.RulesDir); err != nil { + log.Printf("rules load warning: %v", err) + } + + hub = newHub() + go hub.run() + + mux := http.NewServeMux() + registerAuthRoutes(mux) + registerUserRoutes(mux) + registerCompetitionRoutes(mux) + registerPilotRoutes(mux) + registerPenaltyRoutes(mux) + registerRuleRoutes(mux) + registerWSRoutes(mux) + + server := &http.Server{ + Addr: cfg.Addr, + Handler: withLog(withCORS(mux)), + ReadHeaderTimeout: 10 * time.Second, + } + + go func() { + log.Printf("listening on %s (cors_origins=%v)", cfg.Addr, corsOrigins) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %v", err) + } + }() + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = server.Shutdown(ctx) +} + +func withLog(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start)) + }) +} + +func originAllowed(origin string) bool { + if origin == "" { + return false + } + for _, o := range corsOrigins { + if o == "*" { + return true + } + if strings.EqualFold(o, origin) { + return true + } + } + return false +} + +func withCORS(next http.Handler) http.Handler { + if len(corsOrigins) == 0 { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if originAllowed(origin) { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS") + reqHeaders := r.Header.Get("Access-Control-Request-Headers") + if reqHeaders == "" { + reqHeaders = "Content-Type" + } + w.Header().Set("Access-Control-Allow-Headers", reqHeaders) + w.Header().Set("Access-Control-Max-Age", "600") + } + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/models.go b/models.go new file mode 100644 index 0000000..a3caac2 --- /dev/null +++ b/models.go @@ -0,0 +1,60 @@ +package main + +type User struct { + ID int64 `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Language string `json:"language"` + IsSystemAdmin bool `json:"is_system_admin"` +} + +type Competition struct { + ID int64 `json:"id"` + Name string `json:"name"` + AllowAnyScorerEdit bool `json:"allow_any_scorer_edit"` + CreatedAt string `json:"created_at"` + Role string `json:"role,omitempty"` +} + +type CompetitionUser struct { + UserID int64 `json:"user_id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Role string `json:"role"` +} + +type Pilot struct { + ID int64 `json:"id"` + CompetitionID int64 `json:"competition_id"` + Number string `json:"number"` + LastName string `json:"last_name"` + FirstName string `json:"first_name"` + Country string `json:"country"` + BalloonID string `json:"balloon_id"` +} + +type Penalty struct { + ID int64 `json:"id"` + CompetitionID int64 `json:"competition_id"` + Flight string `json:"flight"` + Date string `json:"date"` + PilotNumber string `json:"pilot_number"` + PilotName string `json:"pilot_name"` + RuleNumber string `json:"rule_number"` + Task string `json:"task"` + PenaltiesText string `json:"penalties_text"` + Description string `json:"description"` + CreatedBy int64 `json:"created_by"` + CreatedByName string `json:"created_by_name"` + Transferred bool `json:"transferred"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type Rule struct { + Number string `json:"number"` + Text string `json:"text"` + SuggestedPenalty string `json:"suggested_penalty"` + EscalationMode string `json:"escalation_mode"` + EscalationTiers []string `json:"escalation_tiers,omitempty"` +} diff --git a/penalties.go b/penalties.go new file mode 100644 index 0000000..d7c1bca --- /dev/null +++ b/penalties.go @@ -0,0 +1,293 @@ +package main + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" +) + +func registerPenaltyRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/competitions/{id}/penalties", requireAuth(handleListPenalties)) + mux.HandleFunc("POST /api/competitions/{id}/penalties", requireAuth(handleCreatePenalty)) + mux.HandleFunc("PATCH /api/competitions/{id}/penalties/{pid}", requireAuth(handleUpdatePenalty)) + mux.HandleFunc("DELETE /api/competitions/{id}/penalties/{pid}", requireAuth(handleDeletePenalty)) + mux.HandleFunc("GET /api/competitions/{id}/penalties.csv", requireAuth(handleExportPenalties)) +} + +func loadPenalty(id int64) (*Penalty, error) { + row := db.QueryRow(`SELECT p.id,p.competition_id,p.flight,p.date,p.pilot_number, + COALESCE(pl.last_name || ', ' || pl.first_name, ''), + p.rule_number,p.task,p.penalties_text,p.description,p.created_by, + COALESCE(u.display_name, u.username),p.transferred,p.created_at,p.updated_at + FROM penalties p + LEFT JOIN pilots pl ON pl.competition_id=p.competition_id AND pl.number=p.pilot_number + LEFT JOIN users u ON u.id=p.created_by + WHERE p.id=?`, id) + var pen Penalty + var transferred int + err := row.Scan(&pen.ID, &pen.CompetitionID, &pen.Flight, &pen.Date, &pen.PilotNumber, + &pen.PilotName, &pen.RuleNumber, &pen.Task, &pen.PenaltiesText, &pen.Description, + &pen.CreatedBy, &pen.CreatedByName, &transferred, &pen.CreatedAt, &pen.UpdatedAt) + if err != nil { + return nil, err + } + pen.Transferred = transferred == 1 + return &pen, nil +} + +func handleListPenalties(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + u := userFromCtx(r) + if _, ok := canAccessCompetition(u, id); !ok { + writeError(w, http.StatusForbidden, "forbidden") + return + } + rows, err := db.Query(`SELECT p.id,p.competition_id,p.flight,p.date,p.pilot_number, + COALESCE(pl.last_name || ', ' || pl.first_name, ''), + p.rule_number,p.task,p.penalties_text,p.description,p.created_by, + COALESCE(u.display_name, u.username),p.transferred,p.created_at,p.updated_at + FROM penalties p + LEFT JOIN pilots pl ON pl.competition_id=p.competition_id AND pl.number=p.pilot_number + LEFT JOIN users u ON u.id=p.created_by + WHERE p.competition_id=? ORDER BY p.id DESC`, id) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + defer rows.Close() + out := []Penalty{} + for rows.Next() { + var pen Penalty + var transferred int + rows.Scan(&pen.ID, &pen.CompetitionID, &pen.Flight, &pen.Date, &pen.PilotNumber, + &pen.PilotName, &pen.RuleNumber, &pen.Task, &pen.PenaltiesText, &pen.Description, + &pen.CreatedBy, &pen.CreatedByName, &transferred, &pen.CreatedAt, &pen.UpdatedAt) + pen.Transferred = transferred == 1 + out = append(out, pen) + } + writeJSON(w, http.StatusOK, out) +} + +func handleCreatePenalty(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + u := userFromCtx(r) + role, ok := canAccessCompetition(u, id) + if !ok || (role != "scorer" && role != "chief_scorer" && role != "system_admin") { + writeError(w, http.StatusForbidden, "forbidden") + return + } + var pen Penalty + if err := json.NewDecoder(r.Body).Decode(&pen); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body") + return + } + transferred := 0 + if pen.Transferred { + transferred = 1 + } + res, err := db.Exec(`INSERT INTO penalties(competition_id,flight,date,pilot_number,rule_number,task,penalties_text,description,created_by,transferred) + VALUES(?,?,?,?,?,?,?,?,?,?)`, + id, pen.Flight, pen.Date, pen.PilotNumber, pen.RuleNumber, pen.Task, pen.PenaltiesText, pen.Description, u.ID, transferred) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + pid, _ := res.LastInsertId() + created, err := loadPenalty(pid) + if err != nil { + writeError(w, http.StatusInternalServerError, "load_error") + return + } + hub.broadcast(id, "penalty_created", created) + writeJSON(w, http.StatusCreated, created) +} + +func canEditPenalty(u *User, competitionID, createdBy int64) bool { + role, ok := canAccessCompetition(u, competitionID) + if !ok { + return false + } + if role == "system_admin" || role == "chief_scorer" { + return true + } + if role == "scorer" { + if u.ID == createdBy { + return true + } + var allow int + db.QueryRow("SELECT allow_any_scorer_edit FROM competitions WHERE id=?", competitionID).Scan(&allow) + return allow == 1 + } + return false +} + +func handleUpdatePenalty(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + pid, err := strconv.ParseInt(r.PathValue("pid"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + u := userFromCtx(r) + var createdBy int64 + if err := db.QueryRow("SELECT created_by FROM penalties WHERE id=? AND competition_id=?", pid, id).Scan(&createdBy); err != nil { + writeError(w, http.StatusNotFound, "not_found") + return + } + if !canEditPenalty(u, id, createdBy) { + writeError(w, http.StatusForbidden, "forbidden") + return + } + var req struct { + Flight *string `json:"flight"` + Date *string `json:"date"` + PilotNumber *string `json:"pilot_number"` + RuleNumber *string `json:"rule_number"` + Task *string `json:"task"` + PenaltiesText *string `json:"penalties_text"` + Description *string `json:"description"` + Transferred *bool `json:"transferred"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body") + return + } + sets := []string{} + args := []any{} + if req.Flight != nil { + sets = append(sets, "flight=?") + args = append(args, *req.Flight) + } + if req.Date != nil { + sets = append(sets, "date=?") + args = append(args, *req.Date) + } + if req.PilotNumber != nil { + sets = append(sets, "pilot_number=?") + args = append(args, *req.PilotNumber) + } + if req.RuleNumber != nil { + sets = append(sets, "rule_number=?") + args = append(args, *req.RuleNumber) + } + if req.Task != nil { + sets = append(sets, "task=?") + args = append(args, *req.Task) + } + if req.PenaltiesText != nil { + sets = append(sets, "penalties_text=?") + args = append(args, *req.PenaltiesText) + } + if req.Description != nil { + sets = append(sets, "description=?") + args = append(args, *req.Description) + } + if req.Transferred != nil { + v := 0 + if *req.Transferred { + v = 1 + } + sets = append(sets, "transferred=?") + args = append(args, v) + } + if len(sets) > 0 { + sets = append(sets, "updated_at=datetime('now')") + args = append(args, pid) + query := "UPDATE penalties SET " + strings.Join(sets, ",") + " WHERE id=?" + if _, err := db.Exec(query, args...); err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + } + updated, err := loadPenalty(pid) + if err != nil { + writeError(w, http.StatusInternalServerError, "load_error") + return + } + hub.broadcast(id, "penalty_updated", updated) + writeJSON(w, http.StatusOK, updated) +} + +func handleDeletePenalty(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + pid, err := strconv.ParseInt(r.PathValue("pid"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + u := userFromCtx(r) + var createdBy int64 + if err := db.QueryRow("SELECT created_by FROM penalties WHERE id=? AND competition_id=?", pid, id).Scan(&createdBy); err != nil { + writeError(w, http.StatusNotFound, "not_found") + return + } + if !canEditPenalty(u, id, createdBy) { + writeError(w, http.StatusForbidden, "forbidden") + return + } + db.Exec("DELETE FROM penalties WHERE id=?", pid) + hub.broadcast(id, "penalty_deleted", map[string]int64{"id": pid}) + w.WriteHeader(http.StatusNoContent) +} + +func handleExportPenalties(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + u := userFromCtx(r) + role, ok := canAccessCompetition(u, id) + if !ok || (role != "chief_scorer" && role != "system_admin") { + writeError(w, http.StatusForbidden, "forbidden") + return + } + rows, err := db.Query(`SELECT p.id,p.flight,p.date,p.pilot_number, + COALESCE(pl.last_name || ', ' || pl.first_name, ''), + p.rule_number,p.task,p.penalties_text,p.description, + COALESCE(u.display_name, u.username),p.transferred,p.created_at + FROM penalties p + LEFT JOIN pilots pl ON pl.competition_id=p.competition_id AND pl.number=p.pilot_number + LEFT JOIN users u ON u.id=p.created_by + WHERE p.competition_id=? ORDER BY p.id`, id) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + defer rows.Close() + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"penalties_%d.csv\"", id)) + cw := csv.NewWriter(w) + cw.Write([]string{"id", "flight", "date", "pilot_number", "pilot_name", "rule_number", "task", "penalties", "description", "created_by", "transferred", "created_at"}) + for rows.Next() { + var idv int64 + var flight, date, pnum, pname, rnum, task, pens, desc, creator, createdAt string + var transferred int + rows.Scan(&idv, &flight, &date, &pnum, &pname, &rnum, &task, &pens, &desc, &creator, &transferred, &createdAt) + t := "0" + if transferred == 1 { + t = "1" + } + cw.Write([]string{strconv.FormatInt(idv, 10), flight, date, pnum, pname, rnum, task, pens, desc, creator, t, createdAt}) + } + cw.Flush() +} diff --git a/pilots.go b/pilots.go new file mode 100644 index 0000000..ce67b16 --- /dev/null +++ b/pilots.go @@ -0,0 +1,210 @@ +package main + +import ( + "encoding/csv" + "encoding/json" + "io" + "net/http" + "strconv" + "strings" +) + +func registerPilotRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/competitions/{id}/pilots", requireAuth(handleListPilots)) + mux.HandleFunc("POST /api/competitions/{id}/pilots", requireAuth(handleCreatePilot)) + mux.HandleFunc("PATCH /api/competitions/{id}/pilots/{pid}", requireAuth(handleUpdatePilot)) + mux.HandleFunc("DELETE /api/competitions/{id}/pilots/{pid}", requireAuth(handleDeletePilot)) + mux.HandleFunc("POST /api/competitions/{id}/pilots/import", requireAuth(handleImportPilots)) +} + +func requireChiefOrAdmin(r *http.Request, competitionID int64) bool { + u := userFromCtx(r) + role, ok := canAccessCompetition(u, competitionID) + if !ok { + return false + } + return role == "system_admin" || role == "chief_scorer" +} + +func handleListPilots(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + u := userFromCtx(r) + if _, ok := canAccessCompetition(u, id); !ok { + writeError(w, http.StatusForbidden, "forbidden") + return + } + rows, err := db.Query("SELECT id,competition_id,number,last_name,first_name,country,balloon_id FROM pilots WHERE competition_id=? ORDER BY number", id) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + defer rows.Close() + out := []Pilot{} + for rows.Next() { + var p Pilot + rows.Scan(&p.ID, &p.CompetitionID, &p.Number, &p.LastName, &p.FirstName, &p.Country, &p.BalloonID) + out = append(out, p) + } + writeJSON(w, http.StatusOK, out) +} + +func handleCreatePilot(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + if !requireChiefOrAdmin(r, id) { + writeError(w, http.StatusForbidden, "forbidden") + return + } + var p Pilot + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body") + return + } + p.Number = strings.TrimSpace(p.Number) + if p.Number == "" || p.LastName == "" { + writeError(w, http.StatusBadRequest, "missing_fields") + return + } + res, err := db.Exec( + "INSERT INTO pilots(competition_id,number,last_name,first_name,country,balloon_id) VALUES(?,?,?,?,?,?)", + id, p.Number, p.LastName, p.FirstName, p.Country, p.BalloonID, + ) + if err != nil { + writeError(w, http.StatusConflict, "duplicate_number") + return + } + p.ID, _ = res.LastInsertId() + p.CompetitionID = id + hub.broadcast(id, "pilot_changed", nil) + writeJSON(w, http.StatusCreated, p) +} + +func handleUpdatePilot(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + pid, err := strconv.ParseInt(r.PathValue("pid"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + if !requireChiefOrAdmin(r, id) { + writeError(w, http.StatusForbidden, "forbidden") + return + } + var p Pilot + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body") + return + } + _, err = db.Exec( + "UPDATE pilots SET number=?,last_name=?,first_name=?,country=?,balloon_id=? WHERE id=? AND competition_id=?", + p.Number, p.LastName, p.FirstName, p.Country, p.BalloonID, pid, id, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + hub.broadcast(id, "pilot_changed", nil) + w.WriteHeader(http.StatusNoContent) +} + +func handleDeletePilot(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + pid, err := strconv.ParseInt(r.PathValue("pid"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + if !requireChiefOrAdmin(r, id) { + writeError(w, http.StatusForbidden, "forbidden") + return + } + db.Exec("DELETE FROM pilots WHERE id=? AND competition_id=?", pid, id) + hub.broadcast(id, "pilot_changed", nil) + w.WriteHeader(http.StatusNoContent) +} + +func handleImportPilots(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + if !requireChiefOrAdmin(r, id) { + writeError(w, http.StatusForbidden, "forbidden") + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + writeError(w, http.StatusBadRequest, "read_error") + return + } + reader := csv.NewReader(strings.NewReader(string(body))) + reader.FieldsPerRecord = -1 + records, err := reader.ReadAll() + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_csv") + return + } + tx, err := db.Begin() + if err != nil { + writeError(w, http.StatusInternalServerError, "tx_error") + return + } + defer tx.Rollback() + stmt, _ := tx.Prepare(`INSERT INTO pilots(competition_id,number,last_name,first_name,country,balloon_id) + VALUES(?,?,?,?,?,?) + ON CONFLICT(competition_id,number) DO UPDATE SET + last_name=excluded.last_name, first_name=excluded.first_name, + country=excluded.country, balloon_id=excluded.balloon_id`) + defer stmt.Close() + count := 0 + for i, rec := range records { + if i == 0 { + lower := strings.ToLower(strings.TrimSpace(rec[0])) + if lower == "number" || lower == "nummer" || lower == "no" { + continue + } + } + if len(rec) < 3 { + continue + } + number := strings.TrimSpace(rec[0]) + lastName := strings.TrimSpace(rec[1]) + firstName := strings.TrimSpace(rec[2]) + country := "" + balloon := "" + if len(rec) >= 4 { + country = strings.TrimSpace(rec[3]) + } + if len(rec) >= 5 { + balloon = strings.TrimSpace(rec[4]) + } + if number == "" { + continue + } + if _, err := stmt.Exec(id, number, lastName, firstName, country, balloon); err == nil { + count++ + } + } + if err := tx.Commit(); err != nil { + writeError(w, http.StatusInternalServerError, "commit_error") + return + } + hub.broadcast(id, "pilot_changed", nil) + writeJSON(w, http.StatusOK, map[string]int{"imported": count}) +} diff --git a/rules.go b/rules.go new file mode 100644 index 0000000..483284f --- /dev/null +++ b/rules.go @@ -0,0 +1,115 @@ +package main + +import ( + "encoding/csv" + "net/http" + "os" + "path/filepath" + "strings" + "sync" +) + +var ( + rulesMu sync.RWMutex + rules = map[string]map[string]Rule{} +) + +func loadRules(dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + out := map[string]map[string]Rule{} + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".csv") { + continue + } + lang := strings.TrimSuffix(e.Name(), ".csv") + f, err := os.Open(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + r := csv.NewReader(f) + r.FieldsPerRecord = -1 + records, err := r.ReadAll() + f.Close() + if err != nil { + continue + } + langMap := map[string]Rule{} + for i, rec := range records { + if len(rec) < 4 { + continue + } + if i == 0 { + lower := strings.ToLower(strings.TrimSpace(rec[0])) + if lower == "number" || lower == "rule_number" || lower == "rule" { + continue + } + } + ru := Rule{ + Number: strings.TrimSpace(rec[0]), + Text: strings.TrimSpace(rec[1]), + SuggestedPenalty: strings.TrimSpace(rec[2]), + EscalationMode: strings.TrimSpace(rec[3]), + } + if ru.EscalationMode == "" { + ru.EscalationMode = "same" + } + if strings.HasPrefix(ru.EscalationMode, "escalate:") { + tiers := strings.Split(strings.TrimPrefix(ru.EscalationMode, "escalate:"), "|") + for j, t := range tiers { + tiers[j] = strings.TrimSpace(t) + } + ru.EscalationTiers = tiers + ru.EscalationMode = "escalate" + } + langMap[ru.Number] = ru + } + out[lang] = langMap + } + rulesMu.Lock() + rules = out + rulesMu.Unlock() + return nil +} + +func getRules(lang string) []Rule { + rulesMu.RLock() + defer rulesMu.RUnlock() + m, ok := rules[lang] + if !ok { + m = rules["en"] + } + out := make([]Rule, 0, len(m)) + for _, r := range m { + out = append(out, r) + } + return out +} + +func registerRuleRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/rules", requireAuth(handleListRules)) + mux.HandleFunc("POST /api/rules/reload", requireAdmin(handleReloadRules)) +} + +func handleListRules(w http.ResponseWriter, r *http.Request) { + lang := r.URL.Query().Get("lang") + if lang == "" { + u := userFromCtx(r) + lang = u.Language + } + writeJSON(w, http.StatusOK, getRules(lang)) +} + +func handleReloadRules(w http.ResponseWriter, r *http.Request) { + dir := os.Getenv("RULES_DIR") + if dir == "" { + dir = "rules" + } + if err := loadRules(dir); err != nil { + writeError(w, http.StatusInternalServerError, "load_error") + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/rules/de.csv b/rules/de.csv new file mode 100644 index 0000000..a26cd0b --- /dev/null +++ b/rules/de.csv @@ -0,0 +1,44 @@ +rule_number,rule_text,suggested_penalty,escalation_mode +R3.8,Wettbewerbsnummer,,same +R3.8 Wrng.,Wettbewerbsnummer - Warnung,Warning,same +R6.3.2,FRF verspätet,bis zu 100 TP,same +R6.3.2 Wrng.,FRF verspätet - Warnung,Warning,same +R7.5,PZ-Verletzung PZ##,bis zu 1000 CP,same +R7.8,Unzulässige Deklaration,bis zu 100 TP,same +R8.4.2,Aufgabenreihenfolge,bis zu 1000 TP,same +R8.4.7d,Falsches Logger-Ziel,25 TP,same +R8.4.7m,Falscher Marker,25 TP,same +R8.4.8,"Zwei Marker innerhalb MMA, gewertet auf LM",auf elektronischen Mark gewertet,same +R8.12,Verspäteter Eintritt,"50 TP je angef. Min. (>5 Min), 100 TP wenn <5 Min",same +R9.2.2,Landeigentümer-Erlaubnis,bis zu 250 TP,same +R9.3.1,Missachten des Startleiters,bis zu 200 TP,same +R9.3.2,Schnellverschluss nicht benutzt,,same +R9.4.1,Mehr als 1 Fahrzeug im Startbereich,100 TP,same +R9.4.3,Fahrzeug fährt nach gelber Flagge in den Startbereich,100 TP,same +R9.9,Startperiode überschritten,50 TP je angef. Minute,same +R10.1.3,Ballonkollision,bis zu 1000 CP + RFS,same +R10.2.1,Gefährliches Fliegen,bis zur Disqualifikation + RFS,doubled +R10.2.2,Überschreitung der vertikalen Geschwindigkeitsgrenze,BSA + RFS,same +R10.5,Rücksichtsloses Verhalten,bis zu 1000 CP,same +R10.6,Vieh stören oder Feldfrüchte beschädigen,bis zu 1000 CP,same +R10.8,Kollision,bis zu 500 CP,same +R10.11,Fahrweise,bis zu 500 CP,same +R11.4,Bodenkontakt 1,200 TP,same +R11.5l,Bodenkontakt 2 - leicht,100 TP,same +R11.5s,Bodenkontakt 2 - massiv,500 TP,same +R12.3.5,Verspätete Deklaration,50 TP je angef. Minute,same +R12.5,Veränderte oder nicht zugelassene Marker,bis zu 250 TP,same +R12.7,GMD nicht in Ordnung,50 TP,same +R12.7a,GMD nicht in Ordnung (+50m),+50m,same +R12.8,MKR nicht entrollt,50 TP,same +R12.8a,MKR nicht entrollt (+50m),+50m,same +R12.13.3,Außerhalb der Wertungsperiode,NR,same +R13.3,Distanzverletzung,"2 TP je 0,1% Verletzung (ANG, ELB, LRN: Summe der % Verletzung)",same +R13.3>,Distanzverletzung >25%,NR,same +R13.3.1,Start zu nah am Ziel,"2 TP je 0,1% Verletzung",same +R13.3.3,Landung zu nah am Ziel oder MMA,200 TP,same +R15.1,Deklaration ungültig / nicht konform,,same +R15.5.2,Keine gültige Deklaration,,same +R15.12.2,Zeitlimit überschritten,,same +R15.15,Benachbarte Segmente,,same +RII.18.d,BalloonLive- oder BLS-Handhabung,,same diff --git a/rules/en.csv b/rules/en.csv new file mode 100644 index 0000000..5edc53d --- /dev/null +++ b/rules/en.csv @@ -0,0 +1,44 @@ +rule_number,rule_text,suggested_penalty,escalation_mode +R3.8,Competition number,,same +R3.8 Wrng.,Competition number - Warning,Warning,same +R6.3.2,FRF delayed,up to 100 TP,same +R6.3.2 Wrng.,FRF delayed - Warning,Warning,same +R7.5,PZ Infringement PZ##,up to 1000 CP,same +R7.8,Inappropriate declaration,up to 100 TP,same +R8.4.2,Task Order,up to 1000 TP,same +R8.4.7d,Wrong Loggergoal,25 TP,same +R8.4.7m,Wrong Marker,25 TP,same +R8.4.8,"Two markers inside MMA, scored to LM",scored to electronic mark,same +R8.12,Late Entry,"50 TP per part minute (>5 min), 100 TP if <5 min",same +R9.2.2,Landowner Permission,up to 250 TP,same +R9.3.1,Disregard launch master,up to 200 TP,same +R9.3.2,Quick Release not used,,same +R9.4.1,More than 1 vehicle in launch area,100 TP,same +R9.4.3,Vehicle entering launch area after yellow flag,100 TP,same +R9.9,Launch period exceeded,50 TP per part minute,same +R10.1.3,Balloon Collision,up to 1000 CP + RFS,same +R10.2.1,Dangerous flying,up to disqualification + RFS,doubled +R10.2.2,Exceeding vertical speed limits,BSA + RFS,same +R10.5,Inconsiderate behaviour,up to 1000 CP,same +R10.6,Disturbing livestock or damaging crop,up to 1000 CP,same +R10.8,Collision,up to 500 CP,same +R10.11,Way of driving,up to 500 CP,same +R11.4,Ground Contact 1,200 TP,same +R11.5l,Ground Contact 2 - Light,100 TP,same +R11.5s,Ground Contact 2 - Solid,500 TP,same +R12.3.5,Late declaration,50 TP per part minute,same +R12.5,Modified or unauthorized markers,up to 250 TP,same +R12.7,GMD not ok,50 TP,same +R12.7a,GMD not ok (+50m),+50m,same +R12.8,MKR not unrolled,50 TP,same +R12.8a,MKR not unrolled (+50m),+50m,same +R12.13.3,Out of scoring period,NR,same +R13.3,Distance Infringement,"2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement)",same +R13.3>,Distance Infringement >25%,NR,same +R13.3.1,Take-off too close to goal,2 TP per 0.1% infringement,same +R13.3.3,Landing too close to goal or MMA,200 TP,same +R15.1,Declaration invalid / non compliant,,same +R15.5.2,No valid declaration,,same +R15.12.2,Exceeded Timing limit,,same +R15.15,Adjacent segments,,same +RII.18.d,BalloonLive or BLS handling,,same diff --git a/rules/es.csv b/rules/es.csv new file mode 100644 index 0000000..5edc53d --- /dev/null +++ b/rules/es.csv @@ -0,0 +1,44 @@ +rule_number,rule_text,suggested_penalty,escalation_mode +R3.8,Competition number,,same +R3.8 Wrng.,Competition number - Warning,Warning,same +R6.3.2,FRF delayed,up to 100 TP,same +R6.3.2 Wrng.,FRF delayed - Warning,Warning,same +R7.5,PZ Infringement PZ##,up to 1000 CP,same +R7.8,Inappropriate declaration,up to 100 TP,same +R8.4.2,Task Order,up to 1000 TP,same +R8.4.7d,Wrong Loggergoal,25 TP,same +R8.4.7m,Wrong Marker,25 TP,same +R8.4.8,"Two markers inside MMA, scored to LM",scored to electronic mark,same +R8.12,Late Entry,"50 TP per part minute (>5 min), 100 TP if <5 min",same +R9.2.2,Landowner Permission,up to 250 TP,same +R9.3.1,Disregard launch master,up to 200 TP,same +R9.3.2,Quick Release not used,,same +R9.4.1,More than 1 vehicle in launch area,100 TP,same +R9.4.3,Vehicle entering launch area after yellow flag,100 TP,same +R9.9,Launch period exceeded,50 TP per part minute,same +R10.1.3,Balloon Collision,up to 1000 CP + RFS,same +R10.2.1,Dangerous flying,up to disqualification + RFS,doubled +R10.2.2,Exceeding vertical speed limits,BSA + RFS,same +R10.5,Inconsiderate behaviour,up to 1000 CP,same +R10.6,Disturbing livestock or damaging crop,up to 1000 CP,same +R10.8,Collision,up to 500 CP,same +R10.11,Way of driving,up to 500 CP,same +R11.4,Ground Contact 1,200 TP,same +R11.5l,Ground Contact 2 - Light,100 TP,same +R11.5s,Ground Contact 2 - Solid,500 TP,same +R12.3.5,Late declaration,50 TP per part minute,same +R12.5,Modified or unauthorized markers,up to 250 TP,same +R12.7,GMD not ok,50 TP,same +R12.7a,GMD not ok (+50m),+50m,same +R12.8,MKR not unrolled,50 TP,same +R12.8a,MKR not unrolled (+50m),+50m,same +R12.13.3,Out of scoring period,NR,same +R13.3,Distance Infringement,"2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement)",same +R13.3>,Distance Infringement >25%,NR,same +R13.3.1,Take-off too close to goal,2 TP per 0.1% infringement,same +R13.3.3,Landing too close to goal or MMA,200 TP,same +R15.1,Declaration invalid / non compliant,,same +R15.5.2,No valid declaration,,same +R15.12.2,Exceeded Timing limit,,same +R15.15,Adjacent segments,,same +RII.18.d,BalloonLive or BLS handling,,same diff --git a/rules/fr.csv b/rules/fr.csv new file mode 100644 index 0000000..5edc53d --- /dev/null +++ b/rules/fr.csv @@ -0,0 +1,44 @@ +rule_number,rule_text,suggested_penalty,escalation_mode +R3.8,Competition number,,same +R3.8 Wrng.,Competition number - Warning,Warning,same +R6.3.2,FRF delayed,up to 100 TP,same +R6.3.2 Wrng.,FRF delayed - Warning,Warning,same +R7.5,PZ Infringement PZ##,up to 1000 CP,same +R7.8,Inappropriate declaration,up to 100 TP,same +R8.4.2,Task Order,up to 1000 TP,same +R8.4.7d,Wrong Loggergoal,25 TP,same +R8.4.7m,Wrong Marker,25 TP,same +R8.4.8,"Two markers inside MMA, scored to LM",scored to electronic mark,same +R8.12,Late Entry,"50 TP per part minute (>5 min), 100 TP if <5 min",same +R9.2.2,Landowner Permission,up to 250 TP,same +R9.3.1,Disregard launch master,up to 200 TP,same +R9.3.2,Quick Release not used,,same +R9.4.1,More than 1 vehicle in launch area,100 TP,same +R9.4.3,Vehicle entering launch area after yellow flag,100 TP,same +R9.9,Launch period exceeded,50 TP per part minute,same +R10.1.3,Balloon Collision,up to 1000 CP + RFS,same +R10.2.1,Dangerous flying,up to disqualification + RFS,doubled +R10.2.2,Exceeding vertical speed limits,BSA + RFS,same +R10.5,Inconsiderate behaviour,up to 1000 CP,same +R10.6,Disturbing livestock or damaging crop,up to 1000 CP,same +R10.8,Collision,up to 500 CP,same +R10.11,Way of driving,up to 500 CP,same +R11.4,Ground Contact 1,200 TP,same +R11.5l,Ground Contact 2 - Light,100 TP,same +R11.5s,Ground Contact 2 - Solid,500 TP,same +R12.3.5,Late declaration,50 TP per part minute,same +R12.5,Modified or unauthorized markers,up to 250 TP,same +R12.7,GMD not ok,50 TP,same +R12.7a,GMD not ok (+50m),+50m,same +R12.8,MKR not unrolled,50 TP,same +R12.8a,MKR not unrolled (+50m),+50m,same +R12.13.3,Out of scoring period,NR,same +R13.3,Distance Infringement,"2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement)",same +R13.3>,Distance Infringement >25%,NR,same +R13.3.1,Take-off too close to goal,2 TP per 0.1% infringement,same +R13.3.3,Landing too close to goal or MMA,200 TP,same +R15.1,Declaration invalid / non compliant,,same +R15.5.2,No valid declaration,,same +R15.12.2,Exceeded Timing limit,,same +R15.15,Adjacent segments,,same +RII.18.d,BalloonLive or BLS handling,,same diff --git a/rules/pl.csv b/rules/pl.csv new file mode 100644 index 0000000..5edc53d --- /dev/null +++ b/rules/pl.csv @@ -0,0 +1,44 @@ +rule_number,rule_text,suggested_penalty,escalation_mode +R3.8,Competition number,,same +R3.8 Wrng.,Competition number - Warning,Warning,same +R6.3.2,FRF delayed,up to 100 TP,same +R6.3.2 Wrng.,FRF delayed - Warning,Warning,same +R7.5,PZ Infringement PZ##,up to 1000 CP,same +R7.8,Inappropriate declaration,up to 100 TP,same +R8.4.2,Task Order,up to 1000 TP,same +R8.4.7d,Wrong Loggergoal,25 TP,same +R8.4.7m,Wrong Marker,25 TP,same +R8.4.8,"Two markers inside MMA, scored to LM",scored to electronic mark,same +R8.12,Late Entry,"50 TP per part minute (>5 min), 100 TP if <5 min",same +R9.2.2,Landowner Permission,up to 250 TP,same +R9.3.1,Disregard launch master,up to 200 TP,same +R9.3.2,Quick Release not used,,same +R9.4.1,More than 1 vehicle in launch area,100 TP,same +R9.4.3,Vehicle entering launch area after yellow flag,100 TP,same +R9.9,Launch period exceeded,50 TP per part minute,same +R10.1.3,Balloon Collision,up to 1000 CP + RFS,same +R10.2.1,Dangerous flying,up to disqualification + RFS,doubled +R10.2.2,Exceeding vertical speed limits,BSA + RFS,same +R10.5,Inconsiderate behaviour,up to 1000 CP,same +R10.6,Disturbing livestock or damaging crop,up to 1000 CP,same +R10.8,Collision,up to 500 CP,same +R10.11,Way of driving,up to 500 CP,same +R11.4,Ground Contact 1,200 TP,same +R11.5l,Ground Contact 2 - Light,100 TP,same +R11.5s,Ground Contact 2 - Solid,500 TP,same +R12.3.5,Late declaration,50 TP per part minute,same +R12.5,Modified or unauthorized markers,up to 250 TP,same +R12.7,GMD not ok,50 TP,same +R12.7a,GMD not ok (+50m),+50m,same +R12.8,MKR not unrolled,50 TP,same +R12.8a,MKR not unrolled (+50m),+50m,same +R12.13.3,Out of scoring period,NR,same +R13.3,Distance Infringement,"2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement)",same +R13.3>,Distance Infringement >25%,NR,same +R13.3.1,Take-off too close to goal,2 TP per 0.1% infringement,same +R13.3.3,Landing too close to goal or MMA,200 TP,same +R15.1,Declaration invalid / non compliant,,same +R15.5.2,No valid declaration,,same +R15.12.2,Exceeded Timing limit,,same +R15.15,Adjacent segments,,same +RII.18.d,BalloonLive or BLS handling,,same diff --git a/rules/pt.csv b/rules/pt.csv new file mode 100644 index 0000000..5edc53d --- /dev/null +++ b/rules/pt.csv @@ -0,0 +1,44 @@ +rule_number,rule_text,suggested_penalty,escalation_mode +R3.8,Competition number,,same +R3.8 Wrng.,Competition number - Warning,Warning,same +R6.3.2,FRF delayed,up to 100 TP,same +R6.3.2 Wrng.,FRF delayed - Warning,Warning,same +R7.5,PZ Infringement PZ##,up to 1000 CP,same +R7.8,Inappropriate declaration,up to 100 TP,same +R8.4.2,Task Order,up to 1000 TP,same +R8.4.7d,Wrong Loggergoal,25 TP,same +R8.4.7m,Wrong Marker,25 TP,same +R8.4.8,"Two markers inside MMA, scored to LM",scored to electronic mark,same +R8.12,Late Entry,"50 TP per part minute (>5 min), 100 TP if <5 min",same +R9.2.2,Landowner Permission,up to 250 TP,same +R9.3.1,Disregard launch master,up to 200 TP,same +R9.3.2,Quick Release not used,,same +R9.4.1,More than 1 vehicle in launch area,100 TP,same +R9.4.3,Vehicle entering launch area after yellow flag,100 TP,same +R9.9,Launch period exceeded,50 TP per part minute,same +R10.1.3,Balloon Collision,up to 1000 CP + RFS,same +R10.2.1,Dangerous flying,up to disqualification + RFS,doubled +R10.2.2,Exceeding vertical speed limits,BSA + RFS,same +R10.5,Inconsiderate behaviour,up to 1000 CP,same +R10.6,Disturbing livestock or damaging crop,up to 1000 CP,same +R10.8,Collision,up to 500 CP,same +R10.11,Way of driving,up to 500 CP,same +R11.4,Ground Contact 1,200 TP,same +R11.5l,Ground Contact 2 - Light,100 TP,same +R11.5s,Ground Contact 2 - Solid,500 TP,same +R12.3.5,Late declaration,50 TP per part minute,same +R12.5,Modified or unauthorized markers,up to 250 TP,same +R12.7,GMD not ok,50 TP,same +R12.7a,GMD not ok (+50m),+50m,same +R12.8,MKR not unrolled,50 TP,same +R12.8a,MKR not unrolled (+50m),+50m,same +R12.13.3,Out of scoring period,NR,same +R13.3,Distance Infringement,"2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement)",same +R13.3>,Distance Infringement >25%,NR,same +R13.3.1,Take-off too close to goal,2 TP per 0.1% infringement,same +R13.3.3,Landing too close to goal or MMA,200 TP,same +R15.1,Declaration invalid / non compliant,,same +R15.5.2,No valid declaration,,same +R15.12.2,Exceeded Timing limit,,same +R15.15,Adjacent segments,,same +RII.18.d,BalloonLive or BLS handling,,same diff --git a/rules/ru.csv b/rules/ru.csv new file mode 100644 index 0000000..5edc53d --- /dev/null +++ b/rules/ru.csv @@ -0,0 +1,44 @@ +rule_number,rule_text,suggested_penalty,escalation_mode +R3.8,Competition number,,same +R3.8 Wrng.,Competition number - Warning,Warning,same +R6.3.2,FRF delayed,up to 100 TP,same +R6.3.2 Wrng.,FRF delayed - Warning,Warning,same +R7.5,PZ Infringement PZ##,up to 1000 CP,same +R7.8,Inappropriate declaration,up to 100 TP,same +R8.4.2,Task Order,up to 1000 TP,same +R8.4.7d,Wrong Loggergoal,25 TP,same +R8.4.7m,Wrong Marker,25 TP,same +R8.4.8,"Two markers inside MMA, scored to LM",scored to electronic mark,same +R8.12,Late Entry,"50 TP per part minute (>5 min), 100 TP if <5 min",same +R9.2.2,Landowner Permission,up to 250 TP,same +R9.3.1,Disregard launch master,up to 200 TP,same +R9.3.2,Quick Release not used,,same +R9.4.1,More than 1 vehicle in launch area,100 TP,same +R9.4.3,Vehicle entering launch area after yellow flag,100 TP,same +R9.9,Launch period exceeded,50 TP per part minute,same +R10.1.3,Balloon Collision,up to 1000 CP + RFS,same +R10.2.1,Dangerous flying,up to disqualification + RFS,doubled +R10.2.2,Exceeding vertical speed limits,BSA + RFS,same +R10.5,Inconsiderate behaviour,up to 1000 CP,same +R10.6,Disturbing livestock or damaging crop,up to 1000 CP,same +R10.8,Collision,up to 500 CP,same +R10.11,Way of driving,up to 500 CP,same +R11.4,Ground Contact 1,200 TP,same +R11.5l,Ground Contact 2 - Light,100 TP,same +R11.5s,Ground Contact 2 - Solid,500 TP,same +R12.3.5,Late declaration,50 TP per part minute,same +R12.5,Modified or unauthorized markers,up to 250 TP,same +R12.7,GMD not ok,50 TP,same +R12.7a,GMD not ok (+50m),+50m,same +R12.8,MKR not unrolled,50 TP,same +R12.8a,MKR not unrolled (+50m),+50m,same +R12.13.3,Out of scoring period,NR,same +R13.3,Distance Infringement,"2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement)",same +R13.3>,Distance Infringement >25%,NR,same +R13.3.1,Take-off too close to goal,2 TP per 0.1% infringement,same +R13.3.3,Landing too close to goal or MMA,200 TP,same +R15.1,Declaration invalid / non compliant,,same +R15.5.2,No valid declaration,,same +R15.12.2,Exceeded Timing limit,,same +R15.15,Adjacent segments,,same +RII.18.d,BalloonLive or BLS handling,,same diff --git a/users.go b/users.go new file mode 100644 index 0000000..ee66f88 --- /dev/null +++ b/users.go @@ -0,0 +1,141 @@ +package main + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +func registerUserRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/users", requireAdmin(handleListUsers)) + mux.HandleFunc("POST /api/users", requireAuth(handleCreateUser)) + mux.HandleFunc("DELETE /api/users/{id}", requireAdmin(handleDeleteUser)) + mux.HandleFunc("PATCH /api/users/{id}", requireAdmin(handleAdminUpdateUser)) +} + +func handleListUsers(w http.ResponseWriter, r *http.Request) { + rows, err := db.Query("SELECT id,username,display_name,language,is_system_admin FROM users ORDER BY username") + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + defer rows.Close() + out := []User{} + for rows.Next() { + var u User + var admin int + rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin) + u.IsSystemAdmin = admin == 1 + out = append(out, u) + } + writeJSON(w, http.StatusOK, out) +} + +func handleCreateUser(w http.ResponseWriter, r *http.Request) { + actor := userFromCtx(r) + var req struct { + Username string `json:"username"` + Password string `json:"password"` + DisplayName string `json:"display_name"` + Language string `json:"language"` + IsSystemAdmin bool `json:"is_system_admin"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body") + return + } + req.Username = strings.TrimSpace(req.Username) + if req.Username == "" || req.Password == "" { + writeError(w, http.StatusBadRequest, "missing_fields") + return + } + if req.IsSystemAdmin && !actor.IsSystemAdmin { + writeError(w, http.StatusForbidden, "forbidden") + return + } + if !actor.IsSystemAdmin { + var chiefCount int + db.QueryRow("SELECT COUNT(*) FROM competition_users WHERE user_id=? AND role='chief_scorer'", actor.ID).Scan(&chiefCount) + if chiefCount == 0 { + writeError(w, http.StatusForbidden, "forbidden") + return + } + } + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + writeError(w, http.StatusInternalServerError, "hash_error") + return + } + if req.Language == "" { + req.Language = "en" + } + admin := 0 + if req.IsSystemAdmin { + admin = 1 + } + res, err := db.Exec( + "INSERT INTO users(username,password_hash,display_name,language,is_system_admin) VALUES(?,?,?,?,?)", + req.Username, string(hash), req.DisplayName, req.Language, admin, + ) + if err != nil { + writeError(w, http.StatusConflict, "username_taken") + return + } + id, _ := res.LastInsertId() + u, _ := loadUser(id) + writeJSON(w, http.StatusCreated, u) +} + +func handleDeleteUser(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + actor := userFromCtx(r) + if actor.ID == id { + writeError(w, http.StatusBadRequest, "cannot_delete_self") + return + } + if _, err := db.Exec("DELETE FROM users WHERE id=?", id); err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + var req struct { + DisplayName *string `json:"display_name"` + Password *string `json:"password"` + IsSystemAdmin *bool `json:"is_system_admin"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body") + return + } + if req.DisplayName != nil { + db.Exec("UPDATE users SET display_name=? WHERE id=?", *req.DisplayName, id) + } + if req.Password != nil && *req.Password != "" { + hash, _ := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) + db.Exec("UPDATE users SET password_hash=? WHERE id=?", string(hash), id) + } + if req.IsSystemAdmin != nil { + v := 0 + if *req.IsSystemAdmin { + v = 1 + } + db.Exec("UPDATE users SET is_system_admin=? WHERE id=?", v, id) + } + u, _ := loadUser(id) + writeJSON(w, http.StatusOK, u) +} diff --git a/web/api.js b/web/api.js new file mode 100644 index 0000000..82e7e11 --- /dev/null +++ b/web/api.js @@ -0,0 +1,96 @@ +async function api(method, path, body) { + const opts = { + method, + headers: { "Content-Type": "application/json" }, + credentials: "include", + }; + if (body !== undefined) opts.body = JSON.stringify(body); + const res = await fetch(apiURL(path), opts); + if (res.status === 204) return null; + let data = null; + const text = await res.text(); + if (text) { + try { data = JSON.parse(text); } catch (e) { data = text; } + } + if (!res.ok) { + const err = new Error((data && data.error) || res.statusText); + err.status = res.status; + err.data = data; + throw err; + } + return data; +} + +const API = { + login: (u, p) => api("POST", "/api/login", { username: u, password: p }), + logout: () => api("POST", "/api/logout"), + me: () => api("GET", "/api/me"), + updateMe: (b) => api("PATCH", "/api/me", b), + + listUsers: () => api("GET", "/api/users"), + createUser: (b) => api("POST", "/api/users", b), + deleteUser: (id) => api("DELETE", `/api/users/${id}`), + + listCompetitions: () => api("GET", "/api/competitions"), + createCompetition: (b) => api("POST", "/api/competitions", b), + getCompetition: (id) => api("GET", `/api/competitions/${id}`), + updateCompetition: (id, b) => api("PATCH", `/api/competitions/${id}`, b), + deleteCompetition: (id) => api("DELETE", `/api/competitions/${id}`), + + listMembers: (id) => api("GET", `/api/competitions/${id}/members`), + addMember: (id, b) => api("POST", `/api/competitions/${id}/members`, b), + removeMember: (id, uid) => api("DELETE", `/api/competitions/${id}/members/${uid}`), + + listPilots: (id) => api("GET", `/api/competitions/${id}/pilots`), + createPilot: (id, b) => api("POST", `/api/competitions/${id}/pilots`, b), + updatePilot: (id, pid, b) => api("PATCH", `/api/competitions/${id}/pilots/${pid}`, b), + deletePilot: (id, pid) => api("DELETE", `/api/competitions/${id}/pilots/${pid}`), + importPilots: async (id, csv) => { + const res = await fetch(apiURL(`/api/competitions/${id}/pilots/import`), { + method: "POST", + headers: { "Content-Type": "text/csv" }, + body: csv, + credentials: "include", + }); + if (!res.ok) throw new Error("import_failed"); + return res.json(); + }, + + listPenalties: (id) => api("GET", `/api/competitions/${id}/penalties`), + createPenalty: (id, b) => api("POST", `/api/competitions/${id}/penalties`, b), + updatePenalty: (id, pid, b) => api("PATCH", `/api/competitions/${id}/penalties/${pid}`, b), + deletePenalty: (id, pid) => api("DELETE", `/api/competitions/${id}/penalties/${pid}`), + exportPenaltiesURL: (id) => apiURL(`/api/competitions/${id}/penalties.csv`), + + listRules: (lang) => api("GET", `/api/rules${lang ? "?lang=" + encodeURIComponent(lang) : ""}`), +}; + +function openCompetitionWS(id, handlers) { + const url = wsURL(`/api/competitions/${id}/ws`); + let ws = null; + let closed = false; + let backoff = 1000; + + function connect() { + ws = new WebSocket(url); + ws.onopen = () => { backoff = 1000; if (handlers.onopen) handlers.onopen(); }; + ws.onmessage = (e) => { + try { + const msg = JSON.parse(e.data); + if (handlers.onmessage) handlers.onmessage(msg); + } catch (err) {} + }; + ws.onclose = () => { + if (handlers.onclose) handlers.onclose(); + if (!closed) { + setTimeout(connect, backoff); + backoff = Math.min(backoff * 2, 15000); + } + }; + ws.onerror = () => { try { ws.close(); } catch (e) {} }; + } + connect(); + return { + close: () => { closed = true; try { ws && ws.close(); } catch (e) {} } + }; +} diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..3bf935d --- /dev/null +++ b/web/app.js @@ -0,0 +1,1163 @@ +const el = (tag, attrs, ...children) => { + const node = document.createElement(tag); + if (attrs) { + for (const k in attrs) { + if (k === "class") node.className = attrs[k]; + else if (k === "style") Object.assign(node.style, attrs[k]); + else if (k.startsWith("on") && typeof attrs[k] === "function") node.addEventListener(k.slice(2), attrs[k]); + else if (k === "checked" || k === "disabled" || k === "selected") { + if (attrs[k]) node.setAttribute(k, ""); + } else if (attrs[k] !== false && attrs[k] !== null && attrs[k] !== undefined) { + node.setAttribute(k, attrs[k]); + } + } + } + for (const c of children.flat()) { + if (c === null || c === undefined || c === false) continue; + node.appendChild(typeof c === "string" || typeof c === "number" ? document.createTextNode(String(c)) : c); + } + return node; +}; + +const root = document.getElementById("app"); +const state = { + user: null, + competitions: [], + competition: null, + pilots: [], + penalties: [], + members: [], + users: [], + rules: [], + rulesByNumber: {}, + ws: null, + tab: "penalties", + sort: { col: "id", dir: "desc" }, + pilotSort: { col: "number", dir: "asc" }, + filterUntransferred: false, + editingPenalty: null, + showPenaltyModal: false, + showPilotModal: false, + showMemberModal: false, + showCompetitionModal: false, + showUserModal: false, +}; + +function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); } + +function render() { + if (!state.user) { + renderLogin(); + return; + } + if (state.competition) { + renderCompetition(); + } else { + renderHome(); + } +} + +function renderLogin() { + clear(root); + const usernameInput = el("input", { type: "text", autocomplete: "username", placeholder: t("username") }); + const passwordInput = el("input", { type: "password", autocomplete: "current-password", placeholder: t("password") }); + const errorBox = el("div", { class: "muted", style: { color: "var(--danger)", display: "none" } }); + const langSelect = el("select", + { onchange: (e) => { setLang(e.target.value); renderLogin(); } }, + ...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === CURRENT_LANG }, I18N_NAMES[l])) + ); + + async function doLogin(e) { + e.preventDefault(); + errorBox.style.display = "none"; + try { + const u = await API.login(usernameInput.value, passwordInput.value); + state.user = u; + setLang(u.language); + await loadCompetitions(); + render(); + } catch (err) { + errorBox.textContent = err.status === 401 ? t("invalid_credentials") : (err.message || t("invalid_credentials")); + errorBox.style.display = "block"; + } + } + + const form = el("form", { onsubmit: doLogin, class: "col" }, + el("h1", null, t("login_title")), + el("div", { class: "field" }, el("label", null, t("username")), usernameInput), + el("div", { class: "field" }, el("label", null, t("password")), passwordInput), + errorBox, + el("button", { type: "submit", class: "primary" }, t("sign_in")), + el("div", { class: "field", style: { marginTop: "0.5rem" } }, + el("label", null, t("language")), langSelect + ), + ); + + root.appendChild(el("div", { class: "login-wrap" }, el("div", { class: "login-box" }, form))); + usernameInput.focus(); +} + +function renderTopbar(extra) { + const langSelect = el("select", + { onchange: async (e) => { + const lang = e.target.value; + await API.updateMe({ language: lang }); + state.user.language = lang; + setLang(lang); + await loadRules(); + render(); + } }, + ...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === state.user.language }, I18N_NAMES[l])) + ); + const logoutBtn = el("button", { class: "ghost", onclick: async () => { + await API.logout(); + if (state.ws) { state.ws.close(); state.ws = null; } + state.user = null; + state.competition = null; + render(); + } }, t("logout")); + + const brand = el("a", { href: "#", onclick: (e) => { + e.preventDefault(); + if (state.ws) { state.ws.close(); state.ws = null; } + state.competition = null; + render(); + } , class: "brand" }, "Penalty Tracker"); + + const profileBtn = el("button", { class: "ghost", onclick: () => openProfileModal() }, + state.user.display_name || state.user.username); + + return el("div", { class: "topbar" }, + brand, + el("div", { class: "nav" }, + extra || null, + profileBtn, + langSelect, + logoutBtn, + ) + ); +} + +function openProfileModal() { + state.showProfileModal = true; + render(); +} + +function renderProfileModal() { + const username = el("input", { type: "text", value: state.user.username }); + const displayName = el("input", { type: "text", value: state.user.display_name || "" }); + const password = el("input", { type: "password", placeholder: t("leave_blank_keep") }); + const err = el("div", { class: "muted", style: { color: "var(--danger)", display: "none" } }); + const ok = el("div", { class: "muted", style: { color: "var(--accent)", display: "none" } }); + return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) closeAll(); } }, + el("div", { class: "modal" }, + el("h3", null, t("profile")), + el("div", { class: "field" }, el("label", null, t("username")), username), + el("div", { class: "field" }, el("label", null, t("display_name")), displayName), + el("div", { class: "field" }, el("label", null, t("new_password")), password), + err, ok, + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { onclick: closeAll }, t("cancel")), + el("button", { class: "primary", onclick: async () => { + err.style.display = "none"; ok.style.display = "none"; + const body = {}; + const newUsername = username.value.trim(); + if (newUsername && newUsername !== state.user.username) body.username = newUsername; + if (displayName.value !== (state.user.display_name || "")) body.display_name = displayName.value; + if (password.value) body.password = password.value; + try { + const u = await API.updateMe(body); + state.user = u; + ok.textContent = t("saved"); + ok.style.display = "block"; + setTimeout(() => { closeAll(); }, 700); + } catch (e) { + err.textContent = e.data && e.data.error === "username_taken" ? t("username_taken") : (e.message || "error"); + err.style.display = "block"; + } + } }, t("save")), + ), + ) + ); +} + +async function loadCompetitions() { + state.competitions = await API.listCompetitions(); +} + +async function loadRules() { + state.rules = await API.listRules(state.user.language); + state.rulesByNumber = {}; + for (const r of state.rules) state.rulesByNumber[r.number] = r; +} + +function renderHome() { + clear(root); + root.appendChild(renderTopbar()); + const container = el("div", { class: "container" }); + + const headerRow = el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.75rem" } }, + el("h2", { style: { margin: 0 } }, t("competitions")), + state.user.is_system_admin && el("button", { class: "primary", onclick: () => openCompetitionModal() }, t("new_competition")) + ); + container.appendChild(headerRow); + + if (state.competitions.length === 0) { + container.appendChild(el("div", { class: "muted" }, t("no_competitions"))); + } else { + const list = el("div", { class: "grid" }); + for (const c of state.competitions) { + list.appendChild(el("div", { class: "card" }, + el("h2", null, c.name), + el("div", { class: "muted", style: { marginBottom: "0.5rem" } }, + el("span", { class: "badge accent" }, t(c.role)), + ), + el("button", { class: "primary", onclick: () => openCompetition(c.id) }, t("open")), + )); + } + container.appendChild(list); + } + + if (state.user.is_system_admin) { + container.appendChild(renderUsersAdmin()); + } + + root.appendChild(container); + + if (state.showCompetitionModal) container.appendChild(renderCompetitionModal()); + if (state.showUserModal) container.appendChild(renderUserModal()); + if (state.showProfileModal) container.appendChild(renderProfileModal()); +} + +function renderUsersAdmin() { + const card = el("div", { class: "card" }); + card.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.5rem" } }, + el("h2", { style: { margin: 0 } }, t("users")), + el("button", { onclick: () => openUserModal() }, t("add_user")), + )); + if (state.users.length === 0) { + card.appendChild(el("div", { class: "muted" }, "—")); + return card; + } + const table = el("table"); + table.appendChild(el("thead", null, + el("tr", null, + el("th", null, t("username")), + el("th", null, t("display_name")), + el("th", null, t("language")), + el("th", null, t("is_admin")), + el("th", null, t("actions")), + ) + )); + const tbody = el("tbody"); + for (const u of state.users) { + tbody.appendChild(el("tr", null, + el("td", null, u.username), + el("td", null, u.display_name), + el("td", null, I18N_NAMES[u.language] || u.language), + el("td", null, u.is_system_admin ? t("yes") : t("no")), + el("td", null, + u.id !== state.user.id && el("button", { class: "action-btn danger", onclick: async () => { + if (!confirm(t("confirm_delete"))) return; + await API.deleteUser(u.id); + await loadUsers(); + render(); + } }, t("delete")), + ), + )); + } + table.appendChild(tbody); + card.appendChild(el("div", { class: "table-wrap" }, table)); + return card; +} + +async function loadUsers() { + if (state.user.is_system_admin) { + state.users = await API.listUsers(); + } +} + +function openCompetitionModal() { + state.showCompetitionModal = true; + render(); +} + +function renderCompetitionModal() { + const nameInput = el("input", { type: "text" }); + const allowInput = el("input", { type: "checkbox" }); + const modal = el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target === modal) closeAll(); } }, + el("div", { class: "modal" }, + el("h3", null, t("new_competition")), + el("div", { class: "field" }, el("label", null, t("competition_name")), nameInput), + el("label", { class: "row" }, allowInput, " " + t("allow_any_scorer_edit")), + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { onclick: closeAll }, t("cancel")), + el("button", { class: "primary", onclick: async () => { + if (!nameInput.value.trim()) return; + await API.createCompetition({ name: nameInput.value.trim(), allow_any_scorer_edit: allowInput.checked }); + await loadCompetitions(); + closeAll(); + } }, t("create")), + ), + ) + ); + return modal; +} + +function openUserModal(forCompetition) { + state.showUserModal = true; + state.userModalForCompetition = forCompetition || null; + render(); +} + +function renderUserModal() { + const username = el("input", { type: "text" }); + const password = el("input", { type: "password" }); + const displayName = el("input", { type: "text" }); + const langSelect = el("select", null, + ...I18N_AVAILABLE.map((l) => el("option", { value: l }, I18N_NAMES[l])) + ); + const isAdmin = el("input", { type: "checkbox" }); + const roleSelect = el("select", null, + el("option", { value: "scorer" }, t("scorer")), + el("option", { value: "chief_scorer" }, t("chief_scorer")), + ); + const forComp = state.userModalForCompetition; + return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) closeAll(); } }, + el("div", { class: "modal" }, + el("h3", null, t("add_user")), + el("div", { class: "field" }, el("label", null, t("username")), username), + el("div", { class: "field" }, el("label", null, t("password")), password), + el("div", { class: "field" }, el("label", null, t("display_name")), displayName), + el("div", { class: "field" }, el("label", null, t("language")), langSelect), + !forComp && state.user.is_system_admin && el("label", { class: "row" }, isAdmin, " " + t("is_admin")), + forComp && el("div", { class: "field" }, el("label", null, t("role")), roleSelect), + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { onclick: closeAll }, t("cancel")), + el("button", { class: "primary", onclick: async () => { + if (!username.value.trim() || !password.value) return; + try { + const created = await API.createUser({ + username: username.value.trim(), + password: password.value, + display_name: displayName.value, + language: langSelect.value, + is_system_admin: !forComp && isAdmin.checked, + }); + if (forComp) { + await API.addMember(forComp, { user_id: created.id, role: roleSelect.value }); + await loadMembers(); + } + await loadUsers(); + closeAll(); + } catch (err) { + alert(err.message); + } + } }, t("create")), + ), + ) + ); +} + +function closeAll() { + state.showCompetitionModal = false; + state.showUserModal = false; + state.showPilotModal = false; + state.showPenaltyModal = false; + state.showMemberModal = false; + state.showImportModal = false; + state.showProfileModal = false; + state.editingPenalty = null; + state.editingPilot = null; + state.userModalForCompetition = null; + render(); +} + +async function openCompetition(id) { + const c = await API.getCompetition(id); + state.competition = c; + state.tab = "penalties"; + await Promise.all([loadPilots(), loadPenalties(), loadMembers(), loadRules()]); + if (state.user.is_system_admin) await loadUsers(); + if (state.ws) state.ws.close(); + state.ws = openCompetitionWS(id, { + onopen: () => { state.wsOnline = true; updateStatus(); }, + onclose: () => { state.wsOnline = false; updateStatus(); }, + onmessage: handleWSMessage, + }); + render(); +} + +function updateStatus() { + const el = document.querySelector(".connection-status"); + if (!el) return; + el.classList.toggle("online", !!state.wsOnline); + el.classList.toggle("offline", !state.wsOnline); +} + +function handleWSMessage(msg) { + if (msg.type === "penalty_created") { + const exists = state.penalties.some((p) => p.id === msg.payload.id); + if (!exists) { + state.penalties.unshift(msg.payload); + patchPenaltyTable(); + } + } else if (msg.type === "penalty_updated") { + const idx = state.penalties.findIndex((p) => p.id === msg.payload.id); + if (idx >= 0) { + state.penalties[idx] = msg.payload; + patchPenaltyTable(); + } + } else if (msg.type === "penalty_deleted") { + state.penalties = state.penalties.filter((p) => p.id !== msg.payload.id); + patchPenaltyTable(); + } else if (msg.type === "pilot_changed") { + Promise.all([loadPilots(), loadPenalties()]).then(() => { + if (state.tab === "penalties") patchPenaltyTable(); + else render(); + }); + } else if (msg.type === "competition_updated") { + API.getCompetition(state.competition.id).then((c) => { state.competition = c; render(); }); + } +} + +async function loadPilots() { state.pilots = await API.listPilots(state.competition.id); } +async function loadPenalties() { state.penalties = await API.listPenalties(state.competition.id); } +async function loadMembers() { state.members = await API.listMembers(state.competition.id); } + +function isChief() { + const r = state.competition.role; + return r === "system_admin" || r === "chief_scorer"; +} + +function canEditPenalty(p) { + if (isChief()) return true; + if (p.created_by === state.user.id) return true; + return !!state.competition.allow_any_scorer_edit; +} + +function renderCompetition() { + clear(root); + const backBtn = el("button", { class: "ghost", onclick: () => { + if (state.ws) { state.ws.close(); state.ws = null; } + state.competition = null; + render(); + } }, "← " + t("back")); + root.appendChild(renderTopbar(backBtn)); + + const container = el("div", { class: "container" }); + container.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.5rem" } }, + el("h2", { style: { margin: 0 } }, + state.competition.name, + " ", + el("span", { class: "badge accent" }, t(state.competition.role)), + ), + el("div", { class: "row" }, + el("span", { class: "connection-status " + (state.wsOnline ? "online" : "offline") }), + el("span", { class: "muted" }, state.wsOnline ? t("online") : t("offline")), + ), + )); + + const tabs = el("div", { class: "tabs" }); + const tabDefs = [ + ["penalties", t("penalties")], + ["pilots", t("pilots")], + ["rules", t("rules")], + ]; + if (isChief()) { + tabDefs.push(["members", t("members")]); + tabDefs.push(["settings", t("settings_tab")]); + } + for (const [id, label] of tabDefs) { + tabs.appendChild(el("button", { + class: state.tab === id ? "active" : "", + onclick: () => { state.tab = id; render(); } + }, label)); + } + container.appendChild(tabs); + + if (state.tab === "penalties") container.appendChild(renderPenaltiesTab()); + else if (state.tab === "pilots") container.appendChild(renderPilotsTab()); + else if (state.tab === "rules") container.appendChild(renderRulesTab()); + else if (state.tab === "members") container.appendChild(renderMembersTab()); + else if (state.tab === "settings") container.appendChild(renderSettingsTab()); + + root.appendChild(container); + + if (state.showPenaltyModal) container.appendChild(renderPenaltyModal()); + if (state.showPilotModal) container.appendChild(renderPilotModal()); + if (state.showMemberModal) container.appendChild(renderMemberModal()); + if (state.showUserModal) container.appendChild(renderUserModal()); + if (state.showImportModal) container.appendChild(renderImportModal()); + if (state.showProfileModal) container.appendChild(renderProfileModal()); +} + +const PENALTY_COLS = [ + { key: "id", label: "#" }, + { key: "flight", label: "flight" }, + { key: "date", label: "date" }, + { key: "pilot_number", label: "pilot_number" }, + { key: "pilot_name", label: "pilot_name" }, + { key: "rule_number", label: "rule_number_short" }, + { key: "task", label: "task" }, + { key: "penalties_text", label: "penalty_values" }, + { key: "description", label: "description" }, + { key: "created_by_name", label: "created_by" }, + { key: "transferred", label: "transferred" }, +]; + +function sortPenalties(list) { + const { col, dir } = state.sort; + const sign = dir === "asc" ? 1 : -1; + return [...list].sort((a, b) => { + let av = a[col], bv = b[col]; + if (typeof av === "boolean") av = av ? 1 : 0; + if (typeof bv === "boolean") bv = bv ? 1 : 0; + if (av == null) av = ""; + if (bv == null) bv = ""; + if (typeof av === "number" && typeof bv === "number") return (av - bv) * sign; + return String(av).localeCompare(String(bv), undefined, { numeric: true }) * sign; + }); +} + +function filterPenalties(list) { + if (state.filterUntransferred) list = list.filter((p) => !p.transferred); + return list; +} + +function renderPenaltiesTab() { + const card = el("div"); + + const toolbar = el("div", { class: "toolbar" }, + el("button", { class: "primary", onclick: () => openPenaltyModal() }, t("add_penalty")), + el("label", { class: "row" }, + el("input", { type: "checkbox", checked: state.filterUntransferred, + onchange: (e) => { state.filterUntransferred = e.target.checked; patchPenaltyTable(); } + }), + " " + t("transferred_only") + ), + el("div", { class: "spacer" }), + isChief() && el("a", { href: API.exportPenaltiesURL(state.competition.id), target: "_blank" }, + el("button", null, t("export_csv")) + ), + ); + card.appendChild(toolbar); + + const tableWrap = el("div", { class: "table-wrap", id: "penalty-table-wrap" }); + const table = el("table", { id: "penalty-table" }); + const thead = el("thead"); + const headRow = el("tr"); + for (const c of PENALTY_COLS) { + headRow.appendChild(el("th", { + onclick: () => { + if (state.sort.col === c.key) state.sort.dir = state.sort.dir === "asc" ? "desc" : "asc"; + else { state.sort.col = c.key; state.sort.dir = "asc"; } + patchPenaltyTable(); + } + }, + t(c.label), + el("span", { class: "sort-ind" }, state.sort.col === c.key ? (state.sort.dir === "asc" ? "▲" : "▼") : ""), + )); + } + headRow.appendChild(el("th", null, t("actions"))); + thead.appendChild(headRow); + table.appendChild(thead); + + const tbody = el("tbody", { id: "penalty-tbody" }); + table.appendChild(tbody); + tableWrap.appendChild(table); + card.appendChild(tableWrap); + + setTimeout(patchPenaltyTable, 0); + return card; +} + +function patchPenaltyTable() { + const tbody = document.getElementById("penalty-tbody"); + if (!tbody) return; + const list = sortPenalties(filterPenalties(state.penalties)); + + const headRow = document.querySelector("#penalty-table thead tr"); + if (headRow) { + const ths = headRow.querySelectorAll("th"); + PENALTY_COLS.forEach((c, i) => { + const ind = ths[i].querySelector(".sort-ind"); + if (ind) ind.textContent = state.sort.col === c.key ? (state.sort.dir === "asc" ? "▲" : "▼") : ""; + }); + } + + const existing = new Map(); + for (const tr of Array.from(tbody.children)) { + existing.set(tr.dataset.id, tr); + } + const wanted = new Set(); + let prev = null; + for (const p of list) { + const idStr = String(p.id); + wanted.add(idStr); + let tr = existing.get(idStr); + if (!tr) { + tr = buildPenaltyRow(p); + } else { + updatePenaltyRow(tr, p); + } + if (prev) { + if (prev.nextSibling !== tr) tbody.insertBefore(tr, prev.nextSibling); + } else { + if (tbody.firstChild !== tr) tbody.insertBefore(tr, tbody.firstChild); + } + prev = tr; + } + for (const [k, tr] of existing) { + if (!wanted.has(k)) tr.remove(); + } +} + +function buildPenaltyRow(p) { + const tr = document.createElement("tr"); + tr.dataset.id = String(p.id); + for (const c of PENALTY_COLS) { + const td = document.createElement("td"); + td.dataset.col = c.key; + tr.appendChild(td); + } + const actions = document.createElement("td"); + actions.dataset.col = "_actions"; + tr.appendChild(actions); + updatePenaltyRow(tr, p); + return tr; +} + +function updatePenaltyRow(tr, p) { + tr.classList.toggle("transferred", !!p.transferred); + for (const c of PENALTY_COLS) { + const td = tr.querySelector(`td[data-col="${c.key}"]`); + if (!td) continue; + if (c.key === "transferred") { + td.innerHTML = ""; + const lbl = document.createElement("label"); + lbl.className = "switch"; + const inp = document.createElement("input"); + inp.type = "checkbox"; + inp.checked = !!p.transferred; + inp.disabled = !canEditPenalty(p); + inp.addEventListener("change", async () => { + try { + await API.updatePenalty(state.competition.id, p.id, { transferred: inp.checked }); + } catch (e) { inp.checked = !inp.checked; alert(e.message); } + }); + const slider = document.createElement("span"); + slider.className = "slider"; + lbl.appendChild(inp); + lbl.appendChild(slider); + td.appendChild(lbl); + } else { + const val = p[c.key]; + td.textContent = val == null ? "" : String(val); + } + } + const actions = tr.querySelector('td[data-col="_actions"]'); + actions.innerHTML = ""; + if (canEditPenalty(p)) { + const edit = document.createElement("button"); + edit.className = "action-btn"; + edit.textContent = t("edit"); + edit.addEventListener("click", () => openPenaltyModal(p)); + const del = document.createElement("button"); + del.className = "action-btn danger"; + del.textContent = t("delete"); + del.addEventListener("click", async () => { + if (!confirm(t("confirm_delete"))) return; + await API.deletePenalty(state.competition.id, p.id); + }); + actions.appendChild(edit); + actions.appendChild(del); + } +} + +function openPenaltyModal(penalty) { + state.showPenaltyModal = true; + state.editingPenalty = penalty || null; + render(); +} + +function renderPenaltyModal() { + const p = state.editingPenalty || {}; + const flight = el("input", { type: "text", value: p.flight || "" }); + const date = el("input", { type: "date", value: p.date || new Date().toISOString().slice(0, 10) }); + const pilotSelect = el("select", { onchange: () => refreshPrior() }, + el("option", { value: "" }, "—"), + ...sortPilots(state.pilots).map((pl) => el("option", { value: pl.number, selected: pl.number === p.pilot_number }, + `${pl.number} — ${pl.last_name}, ${pl.first_name}`)) + ); + const existingRule = p.rule_number ? state.rulesByNumber[p.rule_number] : null; + const ruleSearch = el("input", { type: "text", placeholder: t("search_rule"), + value: existingRule ? `${existingRule.number} — ${existingRule.text}` : (p.rule_number || "") }); + const ruleNumber = el("input", { type: "text", placeholder: t("rule_number_short"), value: p.rule_number || "" }); + const suggestionBox = el("div", { class: "rule-card", style: { display: "none" } }); + const searchResults = el("div", { class: "results", style: { display: "none" } }); + const priorBox = el("div", { class: "prior-box", style: { display: "none" } }); + + function escalationViz(r) { + if (r.escalation_mode === "same") return el("span", { class: "muted small" }, t("escalation_same")); + if (r.escalation_mode === "doubled") return el("span", { class: "muted small" }, t("escalation_doubled")); + if (r.escalation_mode === "escalate") { + const wrap = el("div", { class: "tier-row" }); + const tiers = r.escalation_tiers || []; + tiers.forEach((tier, i) => { + wrap.appendChild(el("span", { class: "tier-pill" }, tier)); + if (i < tiers.length - 1) wrap.appendChild(el("span", { class: "tier-arrow" }, "→")); + }); + return wrap; + } + return el("span", null, ""); + } + + function refreshSuggestion() { + const rNum = ruleNumber.value.trim(); + const r = state.rulesByNumber[rNum]; + if (!r) { suggestionBox.style.display = "none"; return; } + suggestionBox.style.display = "block"; + suggestionBox.innerHTML = ""; + suggestionBox.appendChild(el("div", { class: "rule-head" }, + el("span", { class: "rule-num" }, r.number), + el("span", { class: "rule-text" }, r.text), + )); + suggestionBox.appendChild(el("div", { class: "kv" }, + el("span", { class: "k" }, t("suggested_penalty")), + el("span", { class: "badge accent" }, r.suggested_penalty), + )); + suggestionBox.appendChild(el("div", { class: "kv" }, + el("span", { class: "k" }, t("escalation")), + escalationViz(r), + )); + } + + function refreshPrior() { + const rNum = ruleNumber.value.trim(); + const pNum = pilotSelect.value; + if (!rNum || !pNum) { priorBox.style.display = "none"; return; } + const prior = state.penalties.filter((x) => + x.pilot_number === pNum && x.rule_number === rNum && x.id !== p.id + ); + priorBox.innerHTML = ""; + if (prior.length === 0) { priorBox.style.display = "none"; return; } + priorBox.style.display = "block"; + priorBox.appendChild(el("div", { class: "prior-title" }, + t("prior_penalties") + " (" + prior.length + ")" + )); + const tbl = el("table", { class: "mini-table" }); + tbl.appendChild(el("thead", null, el("tr", null, + el("th", null, t("flight")), + el("th", null, t("date")), + el("th", null, t("penalty_values")), + el("th", null, t("description")), + ))); + const tb = el("tbody"); + prior.sort((a, b) => naturalCompare(a.date, b.date) || (a.id - b.id)); + for (const pp of prior) { + tb.appendChild(el("tr", null, + el("td", null, pp.flight), + el("td", null, pp.date), + el("td", null, el("span", { class: "badge accent" }, pp.penalties_text)), + el("td", null, pp.description), + )); + } + tbl.appendChild(tb); + priorBox.appendChild(tbl); + } + + refreshSuggestion(); + refreshPrior(); + + ruleNumber.addEventListener("input", () => { refreshSuggestion(); refreshPrior(); }); + + function searchRules(q) { + q = q.trim().toLowerCase(); + if (!q) return []; + return state.rules.filter((r) => + r.number.toLowerCase().includes(q) || r.text.toLowerCase().includes(q) + ).slice(0, 30); + } + ruleSearch.addEventListener("input", () => { + const items = searchRules(ruleSearch.value); + searchResults.innerHTML = ""; + if (items.length === 0) { searchResults.style.display = "none"; return; } + searchResults.style.display = "block"; + for (const r of items) { + const it = el("div", { class: "item", + onclick: () => { + ruleNumber.value = r.number; + ruleSearch.value = `${r.number} — ${r.text}`; + searchResults.style.display = "none"; + refreshSuggestion(); + refreshPrior(); + } + }, + el("span", { class: "num" }, r.number), + r.text + ); + searchResults.appendChild(it); + } + }); + ruleSearch.addEventListener("blur", () => setTimeout(() => searchResults.style.display = "none", 200)); + + const task = el("input", { type: "text", value: p.task || "" }); + const penaltiesText = el("input", { type: "text", value: p.penalties_text || "", + placeholder: "e.g. 50 CP, Warning, +50m, No Result" }); + const description = el("textarea", null, p.description || ""); + const transferred = el("input", { type: "checkbox", checked: !!p.transferred }); + + return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) closeAll(); } }, + el("div", { class: "modal" }, + el("h3", null, p.id ? t("edit") : t("add_penalty")), + el("div", { class: "grid" }, + el("div", { class: "field" }, el("label", null, t("flight")), flight), + el("div", { class: "field" }, el("label", null, t("date")), date), + el("div", { class: "field" }, el("label", null, t("pilot_number")), pilotSelect), + ), + el("div", { class: "field", style: { marginTop: "0.5rem" } }, + el("label", null, t("search_rule")), + el("div", { class: "search-box" }, ruleSearch, searchResults), + ), + el("div", { class: "field" }, el("label", null, t("rule_number_short")), ruleNumber), + suggestionBox, + priorBox, + el("div", { class: "grid", style: { marginTop: "0.5rem" } }, + el("div", { class: "field" }, el("label", null, t("task")), task), + el("div", { class: "field" }, el("label", null, t("penalty_values")), penaltiesText), + ), + el("div", { class: "field" }, el("label", null, t("description")), description), + el("label", { class: "row", style: { marginTop: "0.5rem" } }, transferred, " " + t("transferred")), + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { onclick: closeAll }, t("cancel")), + el("button", { class: "primary", onclick: async () => { + const body = { + flight: flight.value, + date: date.value, + pilot_number: pilotSelect.value, + rule_number: ruleNumber.value, + task: task.value, + penalties_text: penaltiesText.value, + description: description.value, + transferred: transferred.checked, + }; + try { + if (p.id) { + await API.updatePenalty(state.competition.id, p.id, body); + } else { + await API.createPenalty(state.competition.id, body); + } + closeAll(); + } catch (e) { alert(e.message); } + } }, t("save")), + ), + ) + ); +} + +const PILOT_COLS = [ + { key: "number", label: "number" }, + { key: "last_name", label: "last_name" }, + { key: "first_name", label: "first_name" }, + { key: "country", label: "country" }, + { key: "balloon_id", label: "balloon_id" }, +]; + +function naturalCompare(a, b) { + if (a == null) a = ""; + if (b == null) b = ""; + return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" }); +} + +function sortPilots(list) { + const { col, dir } = state.pilotSort; + const sign = dir === "asc" ? 1 : -1; + return [...list].sort((a, b) => naturalCompare(a[col], b[col]) * sign); +} + +function renderPilotsTab() { + const card = el("div"); + if (isChief()) { + card.appendChild(el("div", { class: "toolbar" }, + el("button", { class: "primary", onclick: () => openPilotModal() }, t("add_pilot")), + el("button", { onclick: () => openImportModal() }, t("import_csv")), + )); + } + if (state.pilots.length === 0) { + card.appendChild(el("div", { class: "muted" }, t("no_pilots"))); + return card; + } + const table = el("table"); + const headRow = el("tr"); + for (const c of PILOT_COLS) { + headRow.appendChild(el("th", { + onclick: () => { + if (state.pilotSort.col === c.key) state.pilotSort.dir = state.pilotSort.dir === "asc" ? "desc" : "asc"; + else { state.pilotSort.col = c.key; state.pilotSort.dir = "asc"; } + render(); + } + }, + t(c.label), + el("span", { class: "sort-ind" }, state.pilotSort.col === c.key ? (state.pilotSort.dir === "asc" ? "▲" : "▼") : ""), + )); + } + if (isChief()) headRow.appendChild(el("th", null, t("actions"))); + table.appendChild(el("thead", null, headRow)); + const tbody = el("tbody"); + const sorted = sortPilots(state.pilots); + for (const pl of sorted) { + tbody.appendChild(el("tr", null, + el("td", null, pl.number), + el("td", null, pl.last_name), + el("td", null, pl.first_name), + el("td", null, pl.country), + el("td", null, pl.balloon_id), + isChief() && el("td", null, + el("button", { class: "action-btn", onclick: () => openPilotModal(pl) }, t("edit")), + el("button", { class: "action-btn danger", onclick: async () => { + if (!confirm(t("confirm_delete"))) return; + await API.deletePilot(state.competition.id, pl.id); + await loadPilots(); + render(); + } }, t("delete")), + ), + )); + } + table.appendChild(tbody); + card.appendChild(el("div", { class: "table-wrap" }, table)); + return card; +} + +function openPilotModal(pilot) { + state.showPilotModal = true; + state.editingPilot = pilot || null; + render(); +} + +function renderPilotModal() { + const p = state.editingPilot || {}; + const number = el("input", { type: "text", value: p.number || "" }); + const lastName = el("input", { type: "text", value: p.last_name || "" }); + const firstName = el("input", { type: "text", value: p.first_name || "" }); + const country = el("input", { type: "text", value: p.country || "" }); + const balloon = el("input", { type: "text", value: p.balloon_id || "" }); + return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) closeAll(); } }, + el("div", { class: "modal" }, + el("h3", null, p.id ? t("edit") : t("add_pilot")), + el("div", { class: "grid" }, + el("div", { class: "field" }, el("label", null, t("number")), number), + el("div", { class: "field" }, el("label", null, t("last_name")), lastName), + el("div", { class: "field" }, el("label", null, t("first_name")), firstName), + el("div", { class: "field" }, el("label", null, t("country")), country), + el("div", { class: "field" }, el("label", null, t("balloon_id")), balloon), + ), + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { onclick: closeAll }, t("cancel")), + el("button", { class: "primary", onclick: async () => { + const body = { + number: number.value.trim(), + last_name: lastName.value.trim(), + first_name: firstName.value.trim(), + country: country.value.trim(), + balloon_id: balloon.value.trim(), + }; + try { + if (p.id) await API.updatePilot(state.competition.id, p.id, body); + else await API.createPilot(state.competition.id, body); + await loadPilots(); + closeAll(); + } catch (e) { alert(e.message); } + } }, t("save")), + ), + ) + ); +} + +function openImportModal() { state.showImportModal = true; render(); } + +function renderImportModal() { + const textarea = el("textarea", { placeholder: t("csv_paste"), style: { width: "100%", minHeight: "180px" } }); + return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) { state.showImportModal = false; render(); } } }, + el("div", { class: "modal" }, + el("h3", null, t("import_csv")), + textarea, + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { onclick: () => { state.showImportModal = false; render(); } }, t("cancel")), + el("button", { class: "primary", onclick: async () => { + try { + await API.importPilots(state.competition.id, textarea.value); + await loadPilots(); + state.showImportModal = false; render(); + } catch (e) { alert(e.message); } + } }, t("import_csv")), + ) + ) + ); +} + +function ruleEscalationViz(r) { + if (r.escalation_mode === "same") return el("span", { class: "muted small" }, t("escalation_same")); + if (r.escalation_mode === "doubled") return el("span", { class: "muted small" }, t("escalation_doubled")); + if (r.escalation_mode === "escalate") { + const wrap = el("div", { class: "tier-row" }); + const tiers = r.escalation_tiers || []; + tiers.forEach((tier, i) => { + wrap.appendChild(el("span", { class: "tier-pill" }, tier)); + if (i < tiers.length - 1) wrap.appendChild(el("span", { class: "tier-arrow" }, "→")); + }); + return wrap; + } + return el("span", null, ""); +} + +function renderRulesTab() { + const card = el("div"); + const search = el("input", { type: "search", placeholder: t("search_rule"), style: { width: "100%" } }); + const count = el("div", { class: "muted", style: { margin: "0.5rem 0" } }); + const list = el("div", { class: "rules-list" }); + + function refresh() { + list.innerHTML = ""; + const q = search.value.trim().toLowerCase(); + const filtered = q + ? state.rules.filter((r) => r.number.toLowerCase().includes(q) || r.text.toLowerCase().includes(q)) + : state.rules; + filtered.sort((a, b) => naturalCompare(a.number, b.number)); + count.textContent = t("showing_n_of_m", { n: filtered.length, m: state.rules.length }); + for (const r of filtered) { + list.appendChild(el("div", { class: "rule-card" }, + el("div", { class: "rule-head" }, + el("span", { class: "rule-num" }, r.number), + el("span", { class: "rule-text" }, r.text), + ), + el("div", { class: "kv" }, + el("span", { class: "k" }, t("suggested_penalty")), + el("span", { class: "badge accent" }, r.suggested_penalty), + ), + el("div", { class: "kv" }, + el("span", { class: "k" }, t("escalation")), + ruleEscalationViz(r), + ), + )); + } + if (filtered.length === 0) list.appendChild(el("div", { class: "muted" }, t("none"))); + } + search.addEventListener("input", refresh); + card.appendChild(search); + card.appendChild(count); + card.appendChild(list); + setTimeout(refresh, 0); + return card; +} + +function renderMembersTab() { + const card = el("div"); + card.appendChild(el("div", { class: "toolbar" }, + el("button", { class: "primary", onclick: () => openMemberModal() }, t("add_member")), + state.user.is_system_admin && el("button", { onclick: () => openUserModal(state.competition.id) }, t("add_user")), + )); + if (state.members.length === 0) { + card.appendChild(el("div", { class: "muted" }, t("no_members"))); + return card; + } + const table = el("table"); + table.appendChild(el("thead", null, el("tr", null, + el("th", null, t("username")), + el("th", null, t("display_name")), + el("th", null, t("role")), + el("th", null, t("actions")), + ))); + const tbody = el("tbody"); + for (const m of state.members) { + tbody.appendChild(el("tr", null, + el("td", null, m.username), + el("td", null, m.display_name), + el("td", null, t(m.role)), + el("td", null, + el("button", { class: "action-btn danger", onclick: async () => { + if (!confirm(t("confirm_delete"))) return; + await API.removeMember(state.competition.id, m.user_id); + await loadMembers(); + render(); + } }, t("remove")), + ), + )); + } + table.appendChild(tbody); + card.appendChild(el("div", { class: "table-wrap" }, table)); + return card; +} + +function openMemberModal() { + state.showMemberModal = true; + render(); +} + +function renderMemberModal() { + const username = el("input", { type: "text", placeholder: t("username") }); + const roleSelect = el("select", null, + el("option", { value: "scorer" }, t("scorer")), + el("option", { value: "chief_scorer" }, t("chief_scorer")), + ); + return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) closeAll(); } }, + el("div", { class: "modal" }, + el("h3", null, t("add_member")), + el("p", { class: "muted" }, t("username")), + username, + el("div", { class: "field", style: { marginTop: "0.5rem" } }, el("label", null, t("role")), roleSelect), + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { onclick: closeAll }, t("cancel")), + el("button", { class: "primary", onclick: async () => { + let users = []; + try { users = await API.listUsers(); } catch (e) { alert(t("forbidden")); return; } + const u = users.find((x) => x.username === username.value.trim()); + if (!u) { alert("User not found"); return; } + await API.addMember(state.competition.id, { user_id: u.id, role: roleSelect.value }); + await loadMembers(); + closeAll(); + } }, t("save")), + ), + ) + ); +} + +function renderSettingsTab() { + const name = el("input", { type: "text", value: state.competition.name }); + const allow = el("input", { type: "checkbox", checked: !!state.competition.allow_any_scorer_edit }); + const msg = el("div", { class: "muted", style: { color: "var(--accent)", display: "none" } }); + return el("div", { class: "card" }, + el("h2", null, t("settings_tab")), + el("div", { class: "field" }, el("label", null, t("competition_name")), name), + el("label", { class: "row", style: { marginTop: "0.5rem" } }, allow, " " + t("allow_any_scorer_edit")), + msg, + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { class: "primary", onclick: async () => { + await API.updateCompetition(state.competition.id, { + name: name.value, allow_any_scorer_edit: allow.checked, + }); + const c = await API.getCompetition(state.competition.id); + state.competition = c; + msg.textContent = t("saved"); + msg.style.display = "block"; + } }, t("save_settings")), + ), + ); +} + +async function init() { + const userLang = navigator.language ? navigator.language.slice(0, 2) : "en"; + setLang(I18N_AVAILABLE.includes(userLang) ? userLang : "en"); + try { + const u = await API.me(); + state.user = u; + setLang(u.language); + await loadCompetitions(); + if (u.is_system_admin) await loadUsers(); + } catch (e) { + state.user = null; + } + render(); +} + +init(); diff --git a/web/config.js b/web/config.js new file mode 100644 index 0000000..45219fb --- /dev/null +++ b/web/config.js @@ -0,0 +1,24 @@ +const API_BASE = "http://127.0.0.1:8080"; + +function getApiBase() { + return API_BASE; +} + +function apiURL(path) { + const base = getApiBase(); + if (!base) return path; + return base + path; +} + +function wsURL(path) { + const base = getApiBase(); + if (base) { + let u; + try { u = new URL(base); } catch (e) { return null; } + u.protocol = u.protocol === "https:" ? "wss:" : "ws:"; + u.pathname = u.pathname.replace(/\/+$/, "") + path; + return u.toString(); + } + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + return `${proto}//${location.host}${path}`; +} diff --git a/web/i18n.js b/web/i18n.js new file mode 100644 index 0000000..e0a6a6c --- /dev/null +++ b/web/i18n.js @@ -0,0 +1,323 @@ +const I18N_AVAILABLE = ["en", "de", "pl", "ru", "fr", "es", "pt"]; +const I18N_NAMES = { + en: "English", de: "Deutsch", pl: "Polski", ru: "Русский", + fr: "Français", es: "Español", pt: "Português" +}; + +const I18N_DATA = { + en: { + login_title: "Sign in", + username: "Username", password: "Password", sign_in: "Sign in", + invalid_credentials: "Invalid username or password", + logout: "Logout", settings: "Settings", language: "Language", + competitions: "Competitions", new_competition: "New competition", + competition_name: "Competition name", create: "Create", cancel: "Cancel", + role: "Role", system_admin: "System Admin", chief_scorer: "Chief Scorer", scorer: "Scorer", + pilots: "Pilots", penalties: "Penalties", members: "Members", rules: "Rules", + settings_tab: "Settings", + number: "Number", last_name: "Last name", first_name: "First name", + country: "Country", balloon_id: "Balloon ID", + add_pilot: "Add pilot", import_csv: "Import CSV", export_csv: "Export CSV", + flight: "Flight", date: "Date", pilot_number: "Pilot No.", + pilot_name: "Pilot name", rule: "Rule", task: "Task", + penalty_values: "Penalties", description: "Description", created_by: "Created by", + transferred: "Transferred", actions: "Actions", + add_penalty: "Add penalty", edit: "Edit", delete: "Delete", save: "Save", + confirm_delete: "Delete this entry?", search_rule: "Search rule by number or text", + suggested_penalty: "Suggested penalty", + escalation: "Escalation behavior", + escalation_same: "Stays the same", escalation_doubled: "Doubled each time", + escalation_escalate: "Escalates: ", + add_member: "Add member", remove: "Remove", + add_user: "Add user", users: "Users", + display_name: "Display name", is_admin: "Admin", + allow_any_scorer_edit: "Allow any scorer to edit penalties", + open: "Open", back: "Back", change_password: "Change password", + new_password: "New password", csv_paste: "Paste CSV (number,last,first,country,balloon)", + no_pilots: "No pilots yet", no_penalties: "No penalties yet", + no_members: "No members yet", no_competitions: "No competitions", + select_pilot: "Select pilot", rule_number_short: "Rule No.", + transferred_only: "Only untransferred", + showing_n_of_m: "Showing {n} of {m}", + online: "Online", offline: "Offline", + forbidden: "Not allowed", + save_settings: "Save settings", + saved: "Saved", + yes: "Yes", no: "No", + backend_url: "Backend URL", + backend_url_hint: "Leave empty to use the same origin (e.g. http://192.168.0.10:8080)", + profile: "Profile", + current_password: "Current password", + leave_blank_keep: "Leave blank to keep current", + username_taken: "Username already taken", + prior_penalties: "Prior penalties for this pilot and rule", + none: "None", + }, + de: { + login_title: "Anmelden", + username: "Benutzername", password: "Passwort", sign_in: "Anmelden", + invalid_credentials: "Falscher Benutzername oder Passwort", + logout: "Abmelden", settings: "Einstellungen", language: "Sprache", + competitions: "Wettbewerbe", new_competition: "Neuer Wettbewerb", + competition_name: "Wettbewerbsname", create: "Erstellen", cancel: "Abbrechen", + role: "Rolle", system_admin: "Systemadmin", chief_scorer: "Chief-Scorer", scorer: "Scorer", + pilots: "Piloten", penalties: "Strafen", members: "Mitglieder", rules: "Regeln", + settings_tab: "Einstellungen", + number: "Nummer", last_name: "Nachname", first_name: "Vorname", + country: "Land", balloon_id: "Ballon-Kennung", + add_pilot: "Pilot hinzufügen", import_csv: "CSV importieren", export_csv: "CSV exportieren", + flight: "Fahrt", date: "Datum", pilot_number: "Pilot-Nr.", + pilot_name: "Pilotenname", rule: "Regel", task: "Aufgabe", + penalty_values: "Strafen", description: "Beschreibung", created_by: "Angelegt von", + transferred: "Übertragen", actions: "Aktionen", + add_penalty: "Strafe hinzufügen", edit: "Bearbeiten", delete: "Löschen", save: "Speichern", + confirm_delete: "Eintrag löschen?", search_rule: "Regel nach Nummer oder Text suchen", + suggested_penalty: "Vorgeschlagene Strafe", + escalation: "Verhalten bei Wiederholung", + escalation_same: "Bleibt gleich", escalation_doubled: "Wird jedes Mal verdoppelt", + escalation_escalate: "Höherstufung: ", + add_member: "Mitglied hinzufügen", remove: "Entfernen", + add_user: "Benutzer anlegen", users: "Benutzer", + display_name: "Anzeigename", is_admin: "Admin", + allow_any_scorer_edit: "Alle Scorer dürfen Strafen bearbeiten", + open: "Öffnen", back: "Zurück", change_password: "Passwort ändern", + new_password: "Neues Passwort", csv_paste: "CSV einfügen (Nr,Nachname,Vorname,Land,Ballon)", + no_pilots: "Noch keine Piloten", no_penalties: "Noch keine Strafen", + no_members: "Noch keine Mitglieder", no_competitions: "Keine Wettbewerbe", + select_pilot: "Pilot wählen", rule_number_short: "Regel-Nr.", + transferred_only: "Nur nicht übertragene", + showing_n_of_m: "{n} von {m}", + online: "Online", offline: "Offline", + forbidden: "Keine Berechtigung", + save_settings: "Einstellungen speichern", + saved: "Gespeichert", + yes: "Ja", no: "Nein", + backend_url: "Backend-URL", + backend_url_hint: "Leer lassen für gleichen Ursprung (z.B. http://192.168.0.10:8080)", + profile: "Profil", + current_password: "Aktuelles Passwort", + leave_blank_keep: "Leer lassen um beizubehalten", + username_taken: "Benutzername bereits vergeben", + prior_penalties: "Frühere Strafen für diesen Piloten und diese Regel", + none: "Keine", + }, + pl: { + login_title: "Zaloguj się", + username: "Nazwa użytkownika", password: "Hasło", sign_in: "Zaloguj", + invalid_credentials: "Nieprawidłowy login lub hasło", + logout: "Wyloguj", settings: "Ustawienia", language: "Język", + competitions: "Zawody", new_competition: "Nowe zawody", + competition_name: "Nazwa zawodów", create: "Utwórz", cancel: "Anuluj", + role: "Rola", system_admin: "Administrator", chief_scorer: "Chief-Scorer", scorer: "Scorer", + pilots: "Piloci", penalties: "Kary", members: "Członkowie", rules: "Zasady", + settings_tab: "Ustawienia", + number: "Numer", last_name: "Nazwisko", first_name: "Imię", + country: "Kraj", balloon_id: "Oznaczenie balonu", + add_pilot: "Dodaj pilota", import_csv: "Import CSV", export_csv: "Eksport CSV", + flight: "Lot", date: "Data", pilot_number: "Nr pilota", + pilot_name: "Imię i nazwisko", rule: "Zasada", task: "Zadanie", + penalty_values: "Kary", description: "Opis", created_by: "Wprowadził", + transferred: "Przesłano", actions: "Akcje", + add_penalty: "Dodaj karę", edit: "Edytuj", delete: "Usuń", save: "Zapisz", + confirm_delete: "Usunąć ten wpis?", search_rule: "Szukaj zasady po numerze lub tekście", + suggested_penalty: "Sugerowana kara", + escalation: "Zachowanie przy powtórzeniu", + escalation_same: "Bez zmian", escalation_doubled: "Podwajana za każdym razem", + escalation_escalate: "Eskalacja: ", + add_member: "Dodaj członka", remove: "Usuń", + add_user: "Dodaj użytkownika", users: "Użytkownicy", + display_name: "Wyświetlana nazwa", is_admin: "Admin", + allow_any_scorer_edit: "Pozwól dowolnemu scorerowi edytować kary", + open: "Otwórz", back: "Wstecz", change_password: "Zmień hasło", + new_password: "Nowe hasło", csv_paste: "Wklej CSV (nr,nazwisko,imię,kraj,balon)", + no_pilots: "Brak pilotów", no_penalties: "Brak kar", + no_members: "Brak członków", no_competitions: "Brak zawodów", + select_pilot: "Wybierz pilota", rule_number_short: "Nr zasady", + transferred_only: "Tylko nieprzesłane", + showing_n_of_m: "{n} z {m}", + online: "Online", offline: "Offline", + forbidden: "Brak uprawnień", + save_settings: "Zapisz ustawienia", + saved: "Zapisano", + yes: "Tak", no: "Nie", + }, + ru: { + login_title: "Вход", + username: "Имя пользователя", password: "Пароль", sign_in: "Войти", + invalid_credentials: "Неверное имя или пароль", + logout: "Выйти", settings: "Настройки", language: "Язык", + competitions: "Соревнования", new_competition: "Новое соревнование", + competition_name: "Название", create: "Создать", cancel: "Отмена", + role: "Роль", system_admin: "Администратор", chief_scorer: "Chief-Scorer", scorer: "Scorer", + pilots: "Пилоты", penalties: "Штрафы", members: "Участники", rules: "Правила", + settings_tab: "Настройки", + number: "Номер", last_name: "Фамилия", first_name: "Имя", + country: "Страна", balloon_id: "Регистрация шара", + add_pilot: "Добавить пилота", import_csv: "Импорт CSV", export_csv: "Экспорт CSV", + flight: "Полёт", date: "Дата", pilot_number: "№ пилота", + pilot_name: "Имя пилота", rule: "Правило", task: "Задание", + penalty_values: "Штрафы", description: "Описание", created_by: "Автор", + transferred: "Передано", actions: "Действия", + add_penalty: "Добавить штраф", edit: "Редактировать", delete: "Удалить", save: "Сохранить", + confirm_delete: "Удалить запись?", search_rule: "Поиск правила по номеру или тексту", + suggested_penalty: "Рекомендованный штраф", + escalation: "Поведение при повторе", + escalation_same: "Без изменений", escalation_doubled: "Удваивается каждый раз", + escalation_escalate: "Эскалация: ", + add_member: "Добавить участника", remove: "Удалить", + add_user: "Создать пользователя", users: "Пользователи", + display_name: "Отображаемое имя", is_admin: "Админ", + allow_any_scorer_edit: "Любой Scorer может редактировать штрафы", + open: "Открыть", back: "Назад", change_password: "Изменить пароль", + new_password: "Новый пароль", csv_paste: "Вставьте CSV (№,фамилия,имя,страна,шар)", + no_pilots: "Нет пилотов", no_penalties: "Нет штрафов", + no_members: "Нет участников", no_competitions: "Нет соревнований", + select_pilot: "Выберите пилота", rule_number_short: "№ правила", + transferred_only: "Только непереданные", + showing_n_of_m: "{n} из {m}", + online: "Онлайн", offline: "Оффлайн", + forbidden: "Нет доступа", + save_settings: "Сохранить настройки", + saved: "Сохранено", + yes: "Да", no: "Нет", + }, + fr: { + login_title: "Connexion", + username: "Nom d'utilisateur", password: "Mot de passe", sign_in: "Connexion", + invalid_credentials: "Identifiants invalides", + logout: "Déconnexion", settings: "Paramètres", language: "Langue", + competitions: "Compétitions", new_competition: "Nouvelle compétition", + competition_name: "Nom", create: "Créer", cancel: "Annuler", + role: "Rôle", system_admin: "Administrateur", chief_scorer: "Chief-Scorer", scorer: "Scorer", + pilots: "Pilotes", penalties: "Pénalités", members: "Membres", rules: "Règles", + settings_tab: "Paramètres", + number: "Numéro", last_name: "Nom", first_name: "Prénom", + country: "Pays", balloon_id: "Immat. ballon", + add_pilot: "Ajouter un pilote", import_csv: "Importer CSV", export_csv: "Exporter CSV", + flight: "Vol", date: "Date", pilot_number: "N° pilote", + pilot_name: "Nom du pilote", rule: "Règle", task: "Épreuve", + penalty_values: "Pénalités", description: "Description", created_by: "Créé par", + transferred: "Transféré", actions: "Actions", + add_penalty: "Ajouter pénalité", edit: "Modifier", delete: "Supprimer", save: "Enregistrer", + confirm_delete: "Supprimer cette entrée ?", search_rule: "Rechercher une règle par numéro ou texte", + suggested_penalty: "Pénalité suggérée", + escalation: "Comportement en cas de répétition", + escalation_same: "Reste identique", escalation_doubled: "Doublée à chaque fois", + escalation_escalate: "Escalade : ", + add_member: "Ajouter membre", remove: "Retirer", + add_user: "Créer un utilisateur", users: "Utilisateurs", + display_name: "Nom affiché", is_admin: "Admin", + allow_any_scorer_edit: "Tous les scorers peuvent modifier les pénalités", + open: "Ouvrir", back: "Retour", change_password: "Changer le mot de passe", + new_password: "Nouveau mot de passe", csv_paste: "Coller CSV (n°,nom,prénom,pays,ballon)", + no_pilots: "Aucun pilote", no_penalties: "Aucune pénalité", + no_members: "Aucun membre", no_competitions: "Aucune compétition", + select_pilot: "Choisir un pilote", rule_number_short: "N° règle", + transferred_only: "Non transférés uniquement", + showing_n_of_m: "{n} sur {m}", + online: "En ligne", offline: "Hors ligne", + forbidden: "Non autorisé", + save_settings: "Enregistrer", + saved: "Enregistré", + yes: "Oui", no: "Non", + }, + es: { + login_title: "Iniciar sesión", + username: "Usuario", password: "Contraseña", sign_in: "Entrar", + invalid_credentials: "Usuario o contraseña incorrectos", + logout: "Salir", settings: "Ajustes", language: "Idioma", + competitions: "Competiciones", new_competition: "Nueva competición", + competition_name: "Nombre", create: "Crear", cancel: "Cancelar", + role: "Rol", system_admin: "Administrador", chief_scorer: "Chief-Scorer", scorer: "Scorer", + pilots: "Pilotos", penalties: "Penalizaciones", members: "Miembros", rules: "Reglas", + settings_tab: "Ajustes", + number: "Número", last_name: "Apellido", first_name: "Nombre", + country: "País", balloon_id: "Matrícula globo", + add_pilot: "Añadir piloto", import_csv: "Importar CSV", export_csv: "Exportar CSV", + flight: "Vuelo", date: "Fecha", pilot_number: "N.º piloto", + pilot_name: "Nombre piloto", rule: "Regla", task: "Tarea", + penalty_values: "Penalizaciones", description: "Descripción", created_by: "Creado por", + transferred: "Transferido", actions: "Acciones", + add_penalty: "Añadir penalización", edit: "Editar", delete: "Eliminar", save: "Guardar", + confirm_delete: "¿Eliminar este registro?", search_rule: "Buscar regla por número o texto", + suggested_penalty: "Penalización sugerida", + escalation: "Comportamiento al repetirse", + escalation_same: "Sin cambios", escalation_doubled: "Se duplica cada vez", + escalation_escalate: "Escala: ", + add_member: "Añadir miembro", remove: "Quitar", + add_user: "Crear usuario", users: "Usuarios", + display_name: "Nombre mostrado", is_admin: "Admin", + allow_any_scorer_edit: "Cualquier scorer puede editar penalizaciones", + open: "Abrir", back: "Atrás", change_password: "Cambiar contraseña", + new_password: "Nueva contraseña", csv_paste: "Pegar CSV (n.º,apellido,nombre,país,globo)", + no_pilots: "Sin pilotos", no_penalties: "Sin penalizaciones", + no_members: "Sin miembros", no_competitions: "Sin competiciones", + select_pilot: "Elegir piloto", rule_number_short: "N.º regla", + transferred_only: "Solo no transferidas", + showing_n_of_m: "{n} de {m}", + online: "En línea", offline: "Sin conexión", + forbidden: "No permitido", + save_settings: "Guardar ajustes", + saved: "Guardado", + yes: "Sí", no: "No", + }, + pt: { + login_title: "Entrar", + username: "Utilizador", password: "Palavra-passe", sign_in: "Entrar", + invalid_credentials: "Utilizador ou palavra-passe inválidos", + logout: "Sair", settings: "Definições", language: "Idioma", + competitions: "Competições", new_competition: "Nova competição", + competition_name: "Nome", create: "Criar", cancel: "Cancelar", + role: "Papel", system_admin: "Administrador", chief_scorer: "Chief-Scorer", scorer: "Scorer", + pilots: "Pilotos", penalties: "Penalizações", members: "Membros", rules: "Regras", + settings_tab: "Definições", + number: "Número", last_name: "Apelido", first_name: "Nome", + country: "País", balloon_id: "Matrícula balão", + add_pilot: "Adicionar piloto", import_csv: "Importar CSV", export_csv: "Exportar CSV", + flight: "Voo", date: "Data", pilot_number: "N.º piloto", + pilot_name: "Nome do piloto", rule: "Regra", task: "Tarefa", + penalty_values: "Penalizações", description: "Descrição", created_by: "Criado por", + transferred: "Transferido", actions: "Ações", + add_penalty: "Adicionar penalização", edit: "Editar", delete: "Eliminar", save: "Guardar", + confirm_delete: "Eliminar este registo?", search_rule: "Procurar regra por número ou texto", + suggested_penalty: "Penalização sugerida", + escalation: "Comportamento em caso de repetição", + escalation_same: "Sem alteração", escalation_doubled: "Duplicada de cada vez", + escalation_escalate: "Escala: ", + add_member: "Adicionar membro", remove: "Remover", + add_user: "Criar utilizador", users: "Utilizadores", + display_name: "Nome mostrado", is_admin: "Admin", + allow_any_scorer_edit: "Qualquer scorer pode editar penalizações", + open: "Abrir", back: "Voltar", change_password: "Alterar palavra-passe", + new_password: "Nova palavra-passe", csv_paste: "Colar CSV (n.º,apelido,nome,país,balão)", + no_pilots: "Sem pilotos", no_penalties: "Sem penalizações", + no_members: "Sem membros", no_competitions: "Sem competições", + select_pilot: "Escolher piloto", rule_number_short: "N.º regra", + transferred_only: "Apenas não transferidas", + showing_n_of_m: "{n} de {m}", + online: "Online", offline: "Offline", + forbidden: "Não autorizado", + save_settings: "Guardar definições", + saved: "Guardado", + yes: "Sim", no: "Não", + }, +}; + +let CURRENT_LANG = "en"; + +function setLang(lang) { + if (!I18N_DATA[lang]) lang = "en"; + CURRENT_LANG = lang; + document.documentElement.lang = lang; +} + +function t(key, vars) { + const d = I18N_DATA[CURRENT_LANG] || I18N_DATA.en; + let str = d[key] || I18N_DATA.en[key] || key; + if (vars) { + for (const k in vars) { + str = str.replaceAll("{" + k + "}", vars[k]); + } + } + return str; +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..870049e --- /dev/null +++ b/web/index.html @@ -0,0 +1,16 @@ + + + + + +Penalty Tracker + + + +
+ + + + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..3bc5fa4 --- /dev/null +++ b/web/style.css @@ -0,0 +1,399 @@ +:root { + --accent: #2b6cb0; + --accent-hover: #2c5282; + --bg: #ffffff; + --fg: #111111; + --muted: #6b7280; + --border: #e5e7eb; + --row-hover: #f9fafb; + --danger: #b91c1c; + --radius: 0.375rem; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background: var(--bg); + color: var(--fg); + font-size: 14px; + line-height: 1.45; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { color: var(--accent-hover); text-decoration: underline; } + +button, input, select, textarea { + font-family: inherit; + font-size: inherit; + color: inherit; + border-radius: var(--radius); + border: 1px solid var(--border); + background: #fff; + padding: 0.4rem 0.6rem; + outline: none; +} + +button { + cursor: pointer; + background: #fff; + transition: background 0.1s ease, border-color 0.1s ease; +} +button:hover { border-color: var(--accent); } +button.primary { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} +button.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); } +button.danger { + background: #fff; + color: var(--danger); + border-color: var(--border); +} +button.danger:hover { border-color: var(--danger); } +button.ghost { border-color: transparent; background: transparent; } + +input:focus, select:focus, textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(43, 108, 176, 0.15); +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.25rem; + border-bottom: 1px solid var(--border); + background: #fff; + position: sticky; + top: 0; + z-index: 10; +} +.topbar .brand { + font-weight: 600; + font-size: 1rem; + color: var(--accent); +} +.topbar .nav { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.container { + padding: 1.25rem; + max-width: 1400px; + margin: 0 auto; +} + +.card { + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; + background: #fff; + margin-bottom: 1rem; +} + +.card h2 { + margin: 0 0 0.75rem 0; + font-size: 1rem; + font-weight: 600; +} + +.row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; } +.col { display: flex; flex-direction: column; gap: 0.5rem; } + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.75rem; +} + +.field { display: flex; flex-direction: column; gap: 0.25rem; } +.field label { font-size: 0.8rem; color: var(--muted); } + +.login-wrap { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 1rem; +} +.login-box { + width: 100%; + max-width: 360px; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.5rem; + background: #fff; +} +.login-box h1 { + margin: 0 0 1rem 0; + font-size: 1.125rem; + color: var(--accent); +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +th, td { + text-align: left; + padding: 0.5rem 0.6rem; + border-bottom: 1px solid var(--border); + vertical-align: top; +} +th { + font-weight: 600; + background: #fafafa; + position: sticky; + top: 0; + cursor: pointer; + user-select: none; + white-space: nowrap; +} +th .sort-ind { + color: var(--muted); + font-size: 0.7rem; + margin-left: 0.25rem; +} +tbody tr:hover { background: var(--row-hover); } +tbody tr.transferred { background: #f3f6fb; } + +.table-wrap { + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: auto; + max-height: 70vh; +} + +.badge { + display: inline-block; + padding: 0.125rem 0.5rem; + border-radius: var(--radius); + font-size: 0.75rem; + background: #f3f4f6; + color: #374151; + border: 1px solid var(--border); +} +.badge.accent { background: rgba(43, 108, 176, 0.1); color: var(--accent); border-color: rgba(43, 108, 176, 0.3); } +.badge.warn { background: #fff7ed; color: #9a3412; border-color: #fed7aa; } + +.muted { color: var(--muted); font-size: 0.85rem; } + +.modal-backdrop { + position: fixed; inset: 0; + background: rgba(0,0,0,0.4); + display: flex; align-items: center; justify-content: center; + z-index: 50; + padding: 1rem; +} +.modal { + background: #fff; + border-radius: var(--radius); + padding: 1.25rem; + max-width: 600px; + width: 100%; + max-height: 90vh; + overflow: auto; +} +.modal h3 { margin-top: 0; } + +.toolbar { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; flex-wrap: wrap; } +.toolbar .spacer { flex: 1; } + +.rule-suggestion { + background: #f9fafb; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.5rem 0.75rem; + font-size: 0.85rem; +} + +.rule-card { + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: var(--radius); + padding: 0.625rem 0.875rem; + background: #fff; + display: flex; + flex-direction: column; + gap: 0.4rem; +} +.rule-head { + display: flex; + align-items: baseline; + gap: 0.5rem; + flex-wrap: wrap; +} +.rule-num { + color: var(--accent); + font-weight: 700; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.85rem; + background: rgba(43, 108, 176, 0.08); + padding: 0.1rem 0.4rem; + border-radius: var(--radius); + white-space: nowrap; +} +.rule-text { font-size: 0.9rem; line-height: 1.3; } +.kv { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + font-size: 0.8rem; +} +.kv .k { color: var(--muted); min-width: 9rem; } +.small { font-size: 0.8rem; } + +.tier-row { + display: inline-flex; + align-items: center; + gap: 0.3rem; + flex-wrap: wrap; +} +.tier-pill { + background: #fff; + border: 1px solid var(--border); + color: var(--fg); + padding: 0.1rem 0.45rem; + border-radius: var(--radius); + font-size: 0.75rem; + font-weight: 500; +} +.tier-pill:last-child { + border-color: var(--accent); + color: var(--accent); + background: rgba(43, 108, 176, 0.08); +} +.tier-arrow { color: var(--muted); font-size: 0.75rem; } + +.rules-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 0.625rem; +} + +.prior-box { + margin-top: 0.5rem; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.5rem 0.75rem; + background: #fafbfc; +} +.prior-title { + font-size: 0.8rem; + font-weight: 600; + color: var(--accent); + margin-bottom: 0.4rem; +} +.mini-table { font-size: 12px; } +.mini-table th, .mini-table td { + padding: 0.3rem 0.4rem; + border-bottom: 1px solid var(--border); + background: transparent; +} +.mini-table th { background: transparent; position: static; } + +.search-box { + position: relative; + width: 100%; +} +.search-box .results { + position: absolute; + top: 100%; left: 0; right: 0; + border: 1px solid var(--border); + background: #fff; + border-radius: var(--radius); + max-height: 240px; + overflow: auto; + z-index: 5; + margin-top: 0.25rem; +} +.search-box .results .item { + padding: 0.5rem 0.6rem; + cursor: pointer; + border-bottom: 1px solid var(--border); +} +.search-box .results .item:last-child { border-bottom: none; } +.search-box .results .item:hover { background: #f3f4f6; } +.search-box .results .item .num { color: var(--accent); font-weight: 600; margin-right: 0.5rem; } + +.switch { + position: relative; + display: inline-block; + width: 36px; + height: 20px; +} +.switch input { opacity: 0; width: 0; height: 0; } +.switch .slider { + position: absolute; + cursor: pointer; + inset: 0; + background-color: #d1d5db; + transition: 0.15s; + border-radius: var(--radius); +} +.switch .slider:before { + position: absolute; + content: ""; + height: 14px; + width: 14px; + left: 3px; + top: 3px; + background-color: white; + transition: 0.15s; + border-radius: var(--radius); +} +.switch input:checked + .slider { background-color: var(--accent); } +.switch input:checked + .slider:before { transform: translateX(16px); } + +.tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + border-bottom: 1px solid var(--border); +} +.tabs button { + background: transparent; + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + padding: 0.5rem 0.75rem; + color: var(--muted); +} +.tabs button.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.action-btn { + padding: 0.25rem 0.5rem; + font-size: 0.8rem; + background: transparent; + border: 1px solid transparent; +} +.action-btn:hover { border-color: var(--border); } + +.connection-status { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: #d1d5db; + margin-right: 0.5rem; +} +.connection-status.online { background: #10b981; } +.connection-status.offline { background: #ef4444; } + +textarea { resize: vertical; min-height: 60px; } + +@media (max-width: 700px) { + .topbar { padding: 0.5rem 0.75rem; } + .container { padding: 0.75rem; } +} diff --git a/ws.go b/ws.go new file mode 100644 index 0000000..1d8abf2 --- /dev/null +++ b/ws.go @@ -0,0 +1,130 @@ +package main + +import ( + "encoding/json" + "net/http" + "strconv" + "sync" + + "github.com/gorilla/websocket" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +type wsMessage struct { + Type string `json:"type"` + Payload any `json:"payload,omitempty"` +} + +type client struct { + conn *websocket.Conn + competitionID int64 + send chan []byte +} + +type Hub struct { + mu sync.RWMutex + clients map[int64]map[*client]struct{} +} + +var hub *Hub + +func newHub() *Hub { + return &Hub{clients: map[int64]map[*client]struct{}{}} +} + +func (h *Hub) run() {} + +func (h *Hub) register(c *client) { + h.mu.Lock() + defer h.mu.Unlock() + set, ok := h.clients[c.competitionID] + if !ok { + set = map[*client]struct{}{} + h.clients[c.competitionID] = set + } + set[c] = struct{}{} +} + +func (h *Hub) unregister(c *client) { + h.mu.Lock() + defer h.mu.Unlock() + if set, ok := h.clients[c.competitionID]; ok { + if _, ok := set[c]; ok { + delete(set, c) + close(c.send) + } + if len(set) == 0 { + delete(h.clients, c.competitionID) + } + } +} + +func (h *Hub) broadcast(competitionID int64, kind string, payload any) { + msg := wsMessage{Type: kind, Payload: payload} + data, err := json.Marshal(msg) + if err != nil { + return + } + h.mu.RLock() + defer h.mu.RUnlock() + set, ok := h.clients[competitionID] + if !ok { + return + } + for c := range set { + select { + case c.send <- data: + default: + } + } +} + +func registerWSRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/competitions/{id}/ws", requireAuth(handleWS)) +} + +func handleWS(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + u := userFromCtx(r) + if _, ok := canAccessCompetition(u, id); !ok { + writeError(w, http.StatusForbidden, "forbidden") + return + } + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + c := &client{conn: conn, competitionID: id, send: make(chan []byte, 16)} + hub.register(c) + go writePump(c) + readPump(c) +} + +func readPump(c *client) { + defer func() { + hub.unregister(c) + c.conn.Close() + }() + c.conn.SetReadLimit(1024) + for { + if _, _, err := c.conn.ReadMessage(); err != nil { + return + } + } +} + +func writePump(c *client) { + defer c.conn.Close() + for msg := range c.send { + if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil { + return + } + } +}