diff --git a/.gitignore b/.gitignore
index 0978c43..5d69134 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
/penaltytracker.db
/penaltytracker.db-shm
/penaltytracker.db-wal
+/config.json
diff --git a/.idea/git_toolbox_prj.xml b/.idea/git_toolbox_prj.xml
new file mode 100644
index 0000000..02b915b
--- /dev/null
+++ b/.idea/git_toolbox_prj.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/auth.go b/auth.go
index b76539b..1afd220 100644
--- a/auth.go
+++ b/auth.go
@@ -6,8 +6,10 @@ import (
"encoding/hex"
"encoding/json"
"errors"
+ "net"
"net/http"
"strings"
+ "sync"
"time"
"golang.org/x/crypto/bcrypt"
@@ -20,8 +22,13 @@ const userCtxKey ctxKey = "user"
const sessionCookie = "pt_session"
const sessionDuration = 24 * time.Hour * 14
+const maxUsernameLen = 64
+const maxDisplayNameLen = 128
+const maxPasswordLen = 256
+const maxFieldLen = 2000
+
func registerAuthRoutes(mux *http.ServeMux) {
- mux.HandleFunc("POST /api/login", handleLogin)
+ mux.HandleFunc("POST /api/login", loginRateLimit(handleLogin))
mux.HandleFunc("POST /api/logout", handleLogout)
mux.HandleFunc("GET /api/me", requireAuth(handleMe))
mux.HandleFunc("PATCH /api/me", requireAuth(handleUpdateMe))
@@ -40,7 +47,7 @@ func ensureDefaultAdmin() error {
return err
}
_, err = db.Exec(
- "INSERT INTO users(username,password_hash,display_name,language,is_system_admin) VALUES(?,?,?,?,1)",
+ "INSERT INTO users(username,password_hash,display_name,language,is_system_admin,must_change_password) VALUES(?,?,?,?,1,1)",
"admin", string(hash), "System Admin", "en",
)
return err
@@ -54,6 +61,79 @@ func newToken() (string, error) {
return hex.EncodeToString(b), nil
}
+// ---- login rate limiter ---------------------------------------------------
+
+type loginAttempts struct {
+ times []time.Time
+}
+
+var (
+ loginMu sync.Mutex
+ loginAttempts_ = map[string]*loginAttempts{}
+)
+
+const loginMaxAttempts = 8
+const loginWindow = 5 * time.Minute
+
+func clientIP(r *http.Request) string {
+ if xf := r.Header.Get("X-Forwarded-For"); xf != "" {
+ parts := strings.Split(xf, ",")
+ return strings.TrimSpace(parts[0])
+ }
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ return r.RemoteAddr
+ }
+ return host
+}
+
+func loginAllowed(ip string) bool {
+ loginMu.Lock()
+ defer loginMu.Unlock()
+ a, ok := loginAttempts_[ip]
+ if !ok {
+ a = &loginAttempts{}
+ loginAttempts_[ip] = a
+ }
+ cutoff := time.Now().Add(-loginWindow)
+ kept := a.times[:0]
+ for _, t := range a.times {
+ if t.After(cutoff) {
+ kept = append(kept, t)
+ }
+ }
+ a.times = kept
+ return len(a.times) < loginMaxAttempts
+}
+
+func loginRecord(ip string) {
+ loginMu.Lock()
+ defer loginMu.Unlock()
+ a, ok := loginAttempts_[ip]
+ if !ok {
+ a = &loginAttempts{}
+ loginAttempts_[ip] = a
+ }
+ a.times = append(a.times, time.Now())
+}
+
+func loginRateLimit(h http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ ip := clientIP(r)
+ if !loginAllowed(ip) {
+ writeError(w, http.StatusTooManyRequests, "too_many_attempts")
+ return
+ }
+ h.ServeHTTP(w, r)
+ }
+}
+
+// ---- handlers --------------------------------------------------------------
+
+func normalizeUsername(s string) string {
+ return strings.ToLower(strings.TrimSpace(s))
+}
+
func handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
@@ -63,19 +143,25 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid_body")
return
}
- req.Username = strings.TrimSpace(req.Username)
+ req.Username = normalizeUsername(req.Username)
if req.Username == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "missing_credentials")
return
}
+ if len(req.Username) > maxUsernameLen || len(req.Password) > maxPasswordLen {
+ writeError(w, http.StatusBadRequest, "too_long")
+ 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 {
+ loginRecord(clientIP(r))
writeError(w, http.StatusUnauthorized, "invalid_credentials")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)); err != nil {
+ loginRecord(clientIP(r))
writeError(w, http.StatusUnauthorized, "invalid_credentials")
return
}
@@ -96,6 +182,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
Expires: expires,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
+ Secure: r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https"),
}
if crossSiteCookies {
cookie.SameSite = http.SameSiteNoneMode
@@ -118,6 +205,7 @@ func handleLogout(w http.ResponseWriter, r *http.Request) {
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
+ Secure: r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https"),
}
if crossSiteCookies {
clear.SameSite = http.SameSiteNoneMode
@@ -132,48 +220,41 @@ func handleMe(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, u)
}
+// handleUpdateMe lets the logged-in user change ONLY their language and password.
+// Username and display name are administrative attributes and can only be set
+// through the admin user endpoints.
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"`
+ Language *string `json:"language"`
+ 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")
+ lang := strings.TrimSpace(*req.Language)
+ if len(lang) > 8 {
+ writeError(w, http.StatusBadRequest, "invalid_language")
return
}
- }
- if req.DisplayName != nil {
- if _, err := db.Exec("UPDATE users SET display_name=? WHERE id=?", *req.DisplayName, u.ID); err != nil {
+ if _, err := db.Exec("UPDATE users SET language=? WHERE id=?", lang, u.ID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error")
return
}
}
if req.Password != nil && *req.Password != "" {
+ if len(*req.Password) > maxPasswordLen {
+ writeError(w, http.StatusBadRequest, "too_long")
+ return
+ }
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 {
+ if _, err := db.Exec("UPDATE users SET password_hash=?,must_change_password=0 WHERE id=?", string(hash), u.ID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error")
return
}
@@ -184,13 +265,14 @@ func handleUpdateMe(w http.ResponseWriter, r *http.Request) {
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)
+ var admin, mustChange int
+ err := db.QueryRow("SELECT id,username,display_name,language,is_system_admin,must_change_password FROM users WHERE id=?", id).
+ Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin, &mustChange)
if err != nil {
return nil, err
}
u.IsSystemAdmin = admin == 1
+ u.MustChangePassword = mustChange == 1
return u, nil
}
@@ -212,6 +294,18 @@ func authUser(r *http.Request) (*User, error) {
return loadUser(userID)
}
+// passwordChangeExempt returns true for endpoints that a user must remain able
+// to access even while in the "must change password" lock state.
+func passwordChangeExempt(r *http.Request) bool {
+ if r.URL.Path == "/api/me" {
+ return true
+ }
+ if r.URL.Path == "/api/logout" {
+ return true
+ }
+ return false
+}
+
func requireAuth(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, err := authUser(r)
@@ -219,6 +313,10 @@ func requireAuth(h http.HandlerFunc) http.HandlerFunc {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
+ if u.MustChangePassword && !passwordChangeExempt(r) {
+ writeError(w, http.StatusForbidden, "password_change_required")
+ return
+ }
ctx := context.WithValue(r.Context(), userCtxKey, u)
h.ServeHTTP(w, r.WithContext(ctx))
}
diff --git a/competitions.go b/competitions.go
index 2c995f5..7f16067 100644
--- a/competitions.go
+++ b/competitions.go
@@ -83,6 +83,10 @@ func handleCreateCompetition(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "missing_name")
return
}
+ if len(req.Name) > 200 {
+ writeError(w, http.StatusBadRequest, "too_long")
+ return
+ }
allow := 0
if req.AllowAnyScorerEdit {
allow = 1
diff --git a/db.go b/db.go
index 332d67b..f7ff3d0 100644
--- a/db.go
+++ b/db.go
@@ -88,5 +88,13 @@ func migrate() error {
return err
}
}
+ // Idempotent column additions for older databases.
+ addColumns := []string{
+ `ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0`,
+ }
+ for _, s := range addColumns {
+ // Ignore "duplicate column" errors so the migration is idempotent.
+ _, _ = db.Exec(s)
+ }
return nil
}
diff --git a/main.go b/main.go
index 22af29a..e0f510a 100644
--- a/main.go
+++ b/main.go
@@ -6,7 +6,9 @@ import (
"errors"
"flag"
"log"
+ "net"
"net/http"
+ "net/url"
"os"
"os/signal"
"path/filepath"
@@ -142,9 +144,11 @@ func main() {
registerRuleRoutes(mux)
registerWSRoutes(mux)
+ handler := withSecurityHeaders(withLog(withCSRF(withCORS(mux))))
+
server := &http.Server{
Addr: cfg.Addr,
- Handler: withLog(withCORS(mux)),
+ Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
}
@@ -171,6 +175,22 @@ func withLog(next http.Handler) http.Handler {
})
}
+func withSecurityHeaders(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ h := w.Header()
+ h.Set("X-Content-Type-Options", "nosniff")
+ h.Set("X-Frame-Options", "DENY")
+ h.Set("Referrer-Policy", "no-referrer")
+ h.Set("Permissions-Policy", "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()")
+ h.Set("Cross-Origin-Resource-Policy", "same-site")
+ h.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
+ if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
+ h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
func originAllowed(origin string) bool {
if origin == "" {
return false
@@ -186,6 +206,65 @@ func originAllowed(origin string) bool {
return false
}
+func sameOriginRequest(r *http.Request) bool {
+ origin := r.Header.Get("Origin")
+ if origin == "" {
+ // Fall back to Referer
+ ref := r.Header.Get("Referer")
+ if ref == "" {
+ // No origin info: only safe methods allowed. State-changing must have Origin.
+ return false
+ }
+ u, err := url.Parse(ref)
+ if err != nil {
+ return false
+ }
+ origin = u.Scheme + "://" + u.Host
+ }
+ u, err := url.Parse(origin)
+ if err != nil {
+ return false
+ }
+ originHost, _, _ := net.SplitHostPort(u.Host)
+ if originHost == "" {
+ originHost = u.Host
+ }
+ reqHost, _, _ := net.SplitHostPort(r.Host)
+ if reqHost == "" {
+ reqHost = r.Host
+ }
+ return strings.EqualFold(originHost, reqHost)
+}
+
+func isStateChanging(method string) bool {
+ switch method {
+ case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
+ return true
+ }
+ return false
+}
+
+func withCSRF(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !isStateChanging(r.Method) {
+ next.ServeHTTP(w, r)
+ return
+ }
+ origin := r.Header.Get("Origin")
+ // Allow if Origin matches a configured CORS origin.
+ if origin != "" && originAllowed(origin) {
+ next.ServeHTTP(w, r)
+ return
+ }
+ // Allow same-origin requests (Origin or Referer host matches request Host).
+ if sameOriginRequest(r) {
+ next.ServeHTTP(w, r)
+ return
+ }
+ writeError(w, http.StatusForbidden, "csrf_forbidden")
+ })
+}
+
func withCORS(next http.Handler) http.Handler {
if len(corsOrigins) == 0 {
return next
diff --git a/models.go b/models.go
index a3caac2..5890c0c 100644
--- a/models.go
+++ b/models.go
@@ -1,11 +1,12 @@
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"`
+ ID int64 `json:"id"`
+ Username string `json:"username"`
+ DisplayName string `json:"display_name"`
+ Language string `json:"language"`
+ IsSystemAdmin bool `json:"is_system_admin"`
+ MustChangePassword bool `json:"must_change_password"`
}
type Competition struct {
diff --git a/penalties.go b/penalties.go
index d7c1bca..3a0c6a9 100644
--- a/penalties.go
+++ b/penalties.go
@@ -9,12 +9,91 @@ import (
"strings"
)
+// csvSafe prefixes potentially dangerous CSV cell content with a single quote
+// so that spreadsheet apps don't interpret it as a formula.
+func csvSafe(s string) string {
+ if s == "" {
+ return s
+ }
+ switch s[0] {
+ case '=', '+', '-', '@', '\t', '\r':
+ return "'" + s
+ }
+ return s
+}
+
+func clip(s string, max int) string {
+ if len(s) > max {
+ return s[:max]
+ }
+ return s
+}
+
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))
+ mux.HandleFunc("POST /api/competitions/{id}/penalties/apply", requireAuth(handleApplyPenalties))
+}
+
+// handleApplyPenalties bulk-marks penalties as applied (transferred=1) for a
+// given task. Requires chief_scorer or system_admin role.
+func handleApplyPenalties(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
+ }
+ var req struct {
+ Task string `json:"task"`
+ IDs []int64 `json:"ids"`
+ Applied bool `json:"applied"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid_body")
+ return
+ }
+ v := 0
+ if req.Applied {
+ v = 1
+ }
+ tx, err := db.Begin()
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "tx_error")
+ return
+ }
+ defer tx.Rollback()
+ if len(req.IDs) > 0 {
+ stmt, err := tx.Prepare("UPDATE penalties SET transferred=?, updated_at=datetime('now') WHERE id=? AND competition_id=?")
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "db_error")
+ return
+ }
+ defer stmt.Close()
+ for _, pid := range req.IDs {
+ stmt.Exec(v, pid, id)
+ }
+ } else {
+ task := clip(req.Task, 64)
+ if _, err := tx.Exec("UPDATE penalties SET transferred=?, updated_at=datetime('now') WHERE competition_id=? AND task=?", v, id, task); err != nil {
+ writeError(w, http.StatusInternalServerError, "db_error")
+ return
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ writeError(w, http.StatusInternalServerError, "commit_error")
+ return
+ }
+ hub.broadcast(id, "penalties_applied", map[string]any{"task": req.Task, "applied": req.Applied, "ids": req.IDs})
+ w.WriteHeader(http.StatusNoContent)
}
func loadPenalty(id int64) (*Penalty, error) {
@@ -92,6 +171,13 @@ func handleCreatePenalty(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid_body")
return
}
+ pen.Flight = clip(pen.Flight, 64)
+ pen.Date = clip(pen.Date, 32)
+ pen.PilotNumber = clip(pen.PilotNumber, 32)
+ pen.RuleNumber = clip(pen.RuleNumber, 64)
+ pen.Task = clip(pen.Task, 64)
+ pen.PenaltiesText = clip(pen.PenaltiesText, 256)
+ pen.Description = clip(pen.Description, maxFieldLen)
transferred := 0
if pen.Transferred {
transferred = 1
@@ -171,31 +257,31 @@ func handleUpdatePenalty(w http.ResponseWriter, r *http.Request) {
args := []any{}
if req.Flight != nil {
sets = append(sets, "flight=?")
- args = append(args, *req.Flight)
+ args = append(args, clip(*req.Flight, 64))
}
if req.Date != nil {
sets = append(sets, "date=?")
- args = append(args, *req.Date)
+ args = append(args, clip(*req.Date, 32))
}
if req.PilotNumber != nil {
sets = append(sets, "pilot_number=?")
- args = append(args, *req.PilotNumber)
+ args = append(args, clip(*req.PilotNumber, 32))
}
if req.RuleNumber != nil {
sets = append(sets, "rule_number=?")
- args = append(args, *req.RuleNumber)
+ args = append(args, clip(*req.RuleNumber, 64))
}
if req.Task != nil {
sets = append(sets, "task=?")
- args = append(args, *req.Task)
+ args = append(args, clip(*req.Task, 64))
}
if req.PenaltiesText != nil {
sets = append(sets, "penalties_text=?")
- args = append(args, *req.PenaltiesText)
+ args = append(args, clip(*req.PenaltiesText, 256))
}
if req.Description != nil {
sets = append(sets, "description=?")
- args = append(args, *req.Description)
+ args = append(args, clip(*req.Description, maxFieldLen))
}
if req.Transferred != nil {
v := 0
@@ -287,7 +373,12 @@ func handleExportPenalties(w http.ResponseWriter, r *http.Request) {
if transferred == 1 {
t = "1"
}
- cw.Write([]string{strconv.FormatInt(idv, 10), flight, date, pnum, pname, rnum, task, pens, desc, creator, t, createdAt})
+ cw.Write([]string{
+ strconv.FormatInt(idv, 10),
+ csvSafe(flight), csvSafe(date), csvSafe(pnum), csvSafe(pname),
+ csvSafe(rnum), csvSafe(task), csvSafe(pens), csvSafe(desc),
+ csvSafe(creator), t, createdAt,
+ })
}
cw.Flush()
}
diff --git a/pilots.go b/pilots.go
index ce67b16..8cbbf5f 100644
--- a/pilots.go
+++ b/pilots.go
@@ -72,6 +72,11 @@ func handleCreatePilot(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "missing_fields")
return
}
+ if len(p.Number) > 32 || len(p.LastName) > 128 || len(p.FirstName) > 128 ||
+ len(p.Country) > 64 || len(p.BalloonID) > 64 {
+ writeError(w, http.StatusBadRequest, "too_long")
+ 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,
diff --git a/users.go b/users.go
index ee66f88..e413c52 100644
--- a/users.go
+++ b/users.go
@@ -4,7 +4,6 @@ import (
"encoding/json"
"net/http"
"strconv"
- "strings"
"golang.org/x/crypto/bcrypt"
)
@@ -17,7 +16,7 @@ func registerUserRoutes(mux *http.ServeMux) {
}
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")
+ rows, err := db.Query("SELECT id,username,display_name,language,is_system_admin,must_change_password FROM users ORDER BY username")
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error")
return
@@ -26,9 +25,10 @@ func handleListUsers(w http.ResponseWriter, r *http.Request) {
out := []User{}
for rows.Next() {
var u User
- var admin int
- rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin)
+ var admin, mustChange int
+ rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin, &mustChange)
u.IsSystemAdmin = admin == 1
+ u.MustChangePassword = mustChange == 1
out = append(out, u)
}
writeJSON(w, http.StatusOK, out)
@@ -47,11 +47,16 @@ func handleCreateUser(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid_body")
return
}
- req.Username = strings.TrimSpace(req.Username)
+ req.Username = normalizeUsername(req.Username)
if req.Username == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "missing_fields")
return
}
+ if len(req.Username) > maxUsernameLen || len(req.Password) > maxPasswordLen ||
+ len(req.DisplayName) > maxDisplayNameLen {
+ writeError(w, http.StatusBadRequest, "too_long")
+ return
+ }
if req.IsSystemAdmin && !actor.IsSystemAdmin {
writeError(w, http.StatusForbidden, "forbidden")
return
@@ -114,18 +119,39 @@ func handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
return
}
var req struct {
- DisplayName *string `json:"display_name"`
- Password *string `json:"password"`
- IsSystemAdmin *bool `json:"is_system_admin"`
+ Username *string `json:"username"`
+ DisplayName *string `json:"display_name"`
+ Password *string `json:"password"`
+ IsSystemAdmin *bool `json:"is_system_admin"`
+ MustChangePassword *bool `json:"must_change_password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body")
return
}
+ if req.Username != nil {
+ newName := normalizeUsername(*req.Username)
+ if newName == "" || len(newName) > maxUsernameLen {
+ writeError(w, http.StatusBadRequest, "invalid_username")
+ return
+ }
+ if _, err := db.Exec("UPDATE users SET username=? WHERE id=?", newName, id); err != nil {
+ writeError(w, http.StatusConflict, "username_taken")
+ return
+ }
+ }
if req.DisplayName != nil {
+ if len(*req.DisplayName) > maxDisplayNameLen {
+ writeError(w, http.StatusBadRequest, "too_long")
+ return
+ }
db.Exec("UPDATE users SET display_name=? WHERE id=?", *req.DisplayName, id)
}
if req.Password != nil && *req.Password != "" {
+ if len(*req.Password) > maxPasswordLen {
+ writeError(w, http.StatusBadRequest, "too_long")
+ return
+ }
hash, _ := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
db.Exec("UPDATE users SET password_hash=? WHERE id=?", string(hash), id)
}
@@ -136,6 +162,13 @@ func handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
}
db.Exec("UPDATE users SET is_system_admin=? WHERE id=?", v, id)
}
+ if req.MustChangePassword != nil {
+ v := 0
+ if *req.MustChangePassword {
+ v = 1
+ }
+ db.Exec("UPDATE users SET must_change_password=? WHERE id=?", v, id)
+ }
u, _ := loadUser(id)
writeJSON(w, http.StatusOK, u)
}
diff --git a/web/api.js b/web/api.js
index 82e7e11..4c84c17 100644
--- a/web/api.js
+++ b/web/api.js
@@ -22,13 +22,14 @@ async function api(method, path, body) {
}
const API = {
- login: (u, p) => api("POST", "/api/login", { username: u, password: p }),
+ login: (u, p) => api("POST", "/api/login", { username: String(u || "").toLowerCase().trim(), 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),
+ updateUser: (id, b) => api("PATCH", `/api/users/${id}`, b),
deleteUser: (id) => api("DELETE", `/api/users/${id}`),
listCompetitions: () => api("GET", "/api/competitions"),
@@ -60,7 +61,12 @@ const API = {
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`),
+ applyPenalties: (id, b) => api("POST", `/api/competitions/${id}/penalties/apply`, b),
+ exportPenaltiesCSV: async (id) => {
+ const res = await fetch(apiURL(`/api/competitions/${id}/penalties.csv`), { credentials: "include" });
+ if (!res.ok) throw new Error("export_failed");
+ return res.blob();
+ },
listRules: (lang) => api("GET", `/api/rules${lang ? "?lang=" + encodeURIComponent(lang) : ""}`),
};
diff --git a/web/app.js b/web/app.js
deleted file mode 100644
index 3bf935d..0000000
--- a/web/app.js
+++ /dev/null
@@ -1,1163 +0,0 @@
-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/common.js b/web/common.js
new file mode 100644
index 0000000..ce52d7f
--- /dev/null
+++ b/web/common.js
@@ -0,0 +1,186 @@
+// Shared helpers used by all pages.
+
+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;
+};
+
+function clearNode(node) {
+ while (node.firstChild) node.removeChild(node.firstChild);
+}
+
+function naturalCompare(a, b) {
+ if (a == null) a = "";
+ if (b == null) b = "";
+ return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" });
+}
+
+// Page navigation helpers (true multi-page navigation, not SPA routing).
+const PAGES = {
+ login: "login.html",
+ competitions: "competitions.html",
+ competition: "competition.html",
+ forcePassword: "force-password.html",
+};
+
+function navigate(page, params) {
+ let url = PAGES[page] || page;
+ if (params) {
+ const qs = new URLSearchParams(params).toString();
+ if (qs) url += "?" + qs;
+ }
+ location.assign(url);
+}
+
+// Bootstraps a page: loads the current user, enforces auth rules.
+// options:
+// requireAuth: true — redirect to login if no session
+// forbidIfMustChange: true — redirect to force-password.html
+// onlyIfMustChange: true — redirect AWAY if user must NOT change
+async function bootstrapAuth(options) {
+ options = options || {};
+ let user = null;
+ try {
+ user = await API.me();
+ } catch (e) {
+ user = null;
+ }
+ if (options.requireAuth && !user) {
+ navigate("login");
+ return null;
+ }
+ if (!user) return null;
+ setLang(user.language || CURRENT_LANG);
+ if (user.must_change_password && options.forbidIfMustChange) {
+ navigate("forcePassword");
+ return null;
+ }
+ if (!user.must_change_password && options.onlyIfMustChange) {
+ navigate("competitions");
+ return null;
+ }
+ return user;
+}
+
+// Standard topbar shown on authenticated pages.
+function renderTopbar(user, opts) {
+ opts = opts || {};
+ const langSelect = el("select",
+ { onchange: async (e) => {
+ const lang = e.target.value;
+ try { await API.updateMe({ language: lang }); } catch (_) {}
+ user.language = lang;
+ setLang(lang);
+ location.reload();
+ } },
+ ...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === user.language }, I18N_NAMES[l]))
+ );
+
+ const logoutBtn = el("button", { class: "ghost", onclick: async () => {
+ try { await API.logout(); } catch (_) {}
+ navigate("login");
+ } }, t("logout"));
+
+ const brand = el("a", { href: PAGES.competitions, class: "brand" }, "Penalty Tracker");
+
+ const profileBtn = el("button", { class: "ghost", onclick: () => openProfileModal(user) },
+ user.display_name || user.username);
+
+ return el("div", { class: "topbar" },
+ brand,
+ el("div", { class: "nav" },
+ opts.extra || null,
+ profileBtn,
+ langSelect,
+ logoutBtn,
+ )
+ );
+}
+
+// Self-contained profile modal: language and password only. Username/display
+// name are read-only here (only system admin can change them).
+function openProfileModal(user) {
+ const backdrop = el("div", { class: "modal-backdrop",
+ onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
+
+ const password = el("input", { type: "password", placeholder: t("leave_blank_keep") });
+ const lang = el("select", null,
+ ...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === user.language }, I18N_NAMES[l]))
+ );
+ const err = el("div", { class: "muted", style: { color: "var(--danger)", display: "none" } });
+ const ok = el("div", { class: "muted", style: { color: "var(--accent)", display: "none" } });
+
+ const usernameField = el("input", { type: "text", value: user.username, disabled: true });
+ const displayField = el("input", { type: "text", value: user.display_name || "", disabled: true });
+
+ const modal = el("div", { class: "modal" },
+ el("h3", null, t("profile")),
+ el("div", { class: "field" }, el("label", null, t("username")), usernameField,
+ el("div", { class: "muted small" }, t("profile_username_readonly"))),
+ el("div", { class: "field" }, el("label", null, t("display_name")), displayField,
+ el("div", { class: "muted small" }, t("profile_displayname_readonly"))),
+ el("div", { class: "field" }, el("label", null, t("language")), lang),
+ 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: () => backdrop.remove() }, t("cancel")),
+ el("button", { class: "primary", onclick: async () => {
+ err.style.display = "none"; ok.style.display = "none";
+ const body = {};
+ if (lang.value !== user.language) body.language = lang.value;
+ if (password.value) body.password = password.value;
+ if (Object.keys(body).length === 0) { backdrop.remove(); return; }
+ try {
+ const u = await API.updateMe(body);
+ if (u.language !== user.language) {
+ user.language = u.language;
+ setLang(u.language);
+ }
+ ok.textContent = t("saved");
+ ok.style.display = "block";
+ setTimeout(() => { backdrop.remove(); location.reload(); }, 600);
+ } catch (e) {
+ err.textContent = (e.data && e.data.error) || e.message || "error";
+ err.style.display = "block";
+ }
+ } }, t("save")),
+ ),
+ );
+ backdrop.appendChild(modal);
+ document.body.appendChild(backdrop);
+}
+
+// Read URL search params as an object.
+function queryParams() {
+ const out = {};
+ const sp = new URLSearchParams(location.search);
+ for (const [k, v] of sp.entries()) out[k] = v;
+ return out;
+}
+
+// Initialize language from saved/browser preference before authenticated state
+// is known.
+(function initInitialLang() {
+ const userLang = navigator.language ? navigator.language.slice(0, 2) : "en";
+ if (typeof I18N_AVAILABLE !== "undefined" && I18N_AVAILABLE.includes(userLang)) {
+ setLang(userLang);
+ } else {
+ setLang("en");
+ }
+})();
diff --git a/web/competition.html b/web/competition.html
new file mode 100644
index 0000000..eb24939
--- /dev/null
+++ b/web/competition.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+Penalty Tracker — Competition
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/competition.js b/web/competition.js
new file mode 100644
index 0000000..590713e
--- /dev/null
+++ b/web/competition.js
@@ -0,0 +1,1232 @@
+(async function () {
+ const root = document.getElementById("app");
+ const user = await bootstrapAuth({ requireAuth: true, forbidIfMustChange: true });
+ if (!user) return;
+
+ const params = queryParams();
+ const competitionId = parseInt(params.id, 10);
+ if (!competitionId) { navigate("competitions"); return; }
+
+ let competition;
+ try {
+ competition = await API.getCompetition(competitionId);
+ } catch (e) {
+ navigate("competitions");
+ return;
+ }
+
+ const state = {
+ competition,
+ pilots: [],
+ penalties: [],
+ members: [],
+ users: [],
+ rules: [],
+ rulesByNumber: {},
+ ws: null,
+ wsOnline: false,
+ tab: params.tab || "penalties",
+ sort: { col: "date", dir: "desc" },
+ pilotSort: { col: "number", dir: "asc" },
+ filterApplied: "all", // "all" | "applied" | "open"
+ filterText: "",
+ };
+
+ async function loadAll() {
+ await Promise.all([loadPilots(), loadPenalties(), loadMembers(), loadRules()]);
+ if (user.is_system_admin) await loadUsers();
+ }
+ async function loadPilots() { state.pilots = await API.listPilots(competitionId); }
+ async function loadPenalties() { state.penalties = await API.listPenalties(competitionId); }
+ async function loadMembers() { state.members = await API.listMembers(competitionId); }
+ async function loadUsers() { state.users = await API.listUsers(); }
+ async function loadRules() {
+ state.rules = await API.listRules(user.language);
+ state.rulesByNumber = {};
+ for (const r of state.rules) state.rulesByNumber[r.number] = r;
+ }
+
+ 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 === user.id) return true;
+ return !!state.competition.allow_any_scorer_edit;
+ }
+
+ function pilotCountForRule(pilotNumber, ruleNumber, excludeId) {
+ if (!pilotNumber || !ruleNumber) return 0;
+ let n = 0;
+ for (const p of state.penalties) {
+ if (p.pilot_number === pilotNumber && p.rule_number === ruleNumber && p.id !== excludeId) n++;
+ }
+ return n;
+ }
+
+ // ---- Penalties tab -----------------------------------------------------
+
+ const PENALTY_COLS = [
+ { 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: "applied" },
+ ];
+
+ 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.filterApplied === "applied") list = list.filter((p) => p.transferred);
+ else if (state.filterApplied === "open") list = list.filter((p) => !p.transferred);
+ const q = state.filterText.trim().toLowerCase();
+ if (q) {
+ list = list.filter((p) =>
+ (p.flight || "").toLowerCase().includes(q) ||
+ (p.date || "").toLowerCase().includes(q) ||
+ (p.pilot_number || "").toLowerCase().includes(q) ||
+ (p.pilot_name || "").toLowerCase().includes(q) ||
+ (p.rule_number || "").toLowerCase().includes(q) ||
+ (p.task || "").toLowerCase().includes(q) ||
+ (p.penalties_text || "").toLowerCase().includes(q) ||
+ (p.description || "").toLowerCase().includes(q) ||
+ (p.created_by_name || "").toLowerCase().includes(q)
+ );
+ }
+ return list;
+ }
+
+ function renderPenaltiesTab() {
+ const card = el("div");
+
+ const search = el("input", { type: "search", placeholder: t("search_penalties"),
+ style: { minWidth: "240px", flex: "1" },
+ value: state.filterText,
+ oninput: (e) => { state.filterText = e.target.value; patchPenaltyTable(); }
+ });
+
+ const applyFilter = el("select", {
+ onchange: (e) => { state.filterApplied = e.target.value; patchPenaltyTable(); }
+ },
+ el("option", { value: "all", selected: state.filterApplied === "all" }, t("filter_all")),
+ el("option", { value: "open", selected: state.filterApplied === "open" }, t("filter_open")),
+ el("option", { value: "applied", selected: state.filterApplied === "applied" }, t("filter_applied")),
+ );
+
+ const toolbar = el("div", { class: "toolbar" },
+ el("button", { class: "primary", onclick: () => openPenaltyModal() }, t("add_penalty")),
+ search,
+ applyFilter,
+ el("div", { class: "spacer" }),
+ isChief() && el("button", { onclick: openApplyByTaskModal }, t("apply_by_task")),
+ isChief() && el("button", { onclick: async () => {
+ try {
+ const blob = await API.exportPenaltiesCSV(competitionId);
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `penalties_${competitionId}.csv`;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ } catch (e) { alert(e.message); }
+ } }, 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", {
+ title: c.title ? t(c.title) : null,
+ 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);
+
+ const status = el("div", { class: "muted", id: "penalty-count", style: { marginTop: "0.5rem" } });
+ card.appendChild(status);
+
+ setTimeout(patchPenaltyTable, 0);
+ return card;
+ }
+
+ function patchPenaltyTable() {
+ const tbody = document.getElementById("penalty-tbody");
+ if (!tbody) return;
+ const list = sortPenalties(filterPenalties(state.penalties));
+
+ const status = document.getElementById("penalty-count");
+ if (status) status.textContent = t("showing_n_of_m", { n: list.length, m: state.penalties.length });
+
+ 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(competitionId, 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 = "";
+ const summary = document.createElement("button");
+ summary.className = "action-btn";
+ summary.textContent = t("summary");
+ summary.addEventListener("click", () => openPenaltySummary(p));
+ actions.appendChild(summary);
+ 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(competitionId, p.id);
+ });
+ actions.appendChild(edit);
+ actions.appendChild(del);
+ }
+ }
+
+ function openPenaltySummary(p) {
+ const rule = p.rule_number ? state.rulesByNumber[p.rule_number] : null;
+ const count = pilotCountForRule(p.pilot_number, p.rule_number, p.id);
+ const backdrop = el("div", { class: "modal-backdrop",
+ onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
+
+ function escalationViz(r) {
+ if (!r) return el("span", null, "");
+ 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" });
+ (r.escalation_tiers || []).forEach((tier, i, arr) => {
+ wrap.appendChild(el("span", { class: "tier-pill" }, tier));
+ if (i < arr.length - 1) wrap.appendChild(el("span", { class: "tier-arrow" }, "→"));
+ });
+ return wrap;
+ }
+ return el("span", null, "");
+ }
+
+ const incidentsBox = el("div", { style: { display: "none", marginTop: "0.5rem" } });
+ const incidentsBtn = el("button", { class: "action-btn", disabled: count === 0,
+ onclick: () => {
+ if (incidentsBox.style.display === "none") {
+ incidentsBox.innerHTML = "";
+ const prior = state.penalties
+ .filter((x) => x.pilot_number === p.pilot_number && x.rule_number === p.rule_number && x.id !== p.id)
+ .sort((a, b) => naturalCompare(a.date, b.date) || (a.id - b.id));
+ 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("task")),
+ el("th", null, t("penalty_values")),
+ el("th", null, t("description")),
+ el("th", null, t("applied")),
+ )));
+ const tb = el("tbody");
+ for (const pp of prior) {
+ tb.appendChild(el("tr", null,
+ el("td", null, pp.flight || ""),
+ el("td", null, pp.date || ""),
+ el("td", null, pp.task || ""),
+ el("td", null, el("span", { class: "badge accent" }, pp.penalties_text || "")),
+ el("td", null, pp.description || ""),
+ el("td", null, pp.transferred ? t("yes") : t("no")),
+ ));
+ }
+ tbl.appendChild(tb);
+ incidentsBox.appendChild(tbl);
+ incidentsBox.style.display = "block";
+ incidentsBtn.textContent = t("hide_incidents");
+ } else {
+ incidentsBox.style.display = "none";
+ incidentsBox.innerHTML = "";
+ incidentsBtn.textContent = t("show_incidents") + " (" + count + ")";
+ }
+ } }, t("show_incidents") + " (" + count + ")");
+
+ backdrop.appendChild(el("div", { class: "modal" },
+ el("h3", null, t("penalty_summary")),
+ el("div", { class: "kv" }, el("span", { class: "k" }, t("pilot_number")),
+ el("span", null, p.pilot_number + (p.pilot_name ? " — " + p.pilot_name : ""))),
+ el("div", { class: "kv" }, el("span", { class: "k" }, t("flight")), el("span", null, p.flight || "—")),
+ el("div", { class: "kv" }, el("span", { class: "k" }, t("date")), el("span", null, p.date || "—")),
+ el("div", { class: "kv" }, el("span", { class: "k" }, t("task")), el("span", null, p.task || "—")),
+ el("div", { class: "kv" }, el("span", { class: "k" }, t("penalty_values")),
+ el("span", { class: "badge accent" }, p.penalties_text || "—")),
+ el("div", { class: "kv" }, el("span", { class: "k" }, t("description")), el("span", null, p.description || "—")),
+ el("div", { class: "kv" }, el("span", { class: "k" }, t("created_by")), el("span", null, p.created_by_name || "—")),
+ el("div", { class: "kv" }, el("span", { class: "k" }, t("applied")),
+ el("span", { class: p.transferred ? "badge accent" : "badge" }, p.transferred ? t("yes") : t("no"))),
+ el("div", { class: "row", style: { marginTop: "0.5rem" } }, incidentsBtn),
+ incidentsBox,
+ el("h4", { style: { marginTop: "1rem", marginBottom: "0.25rem" } }, t("rule")),
+ rule ? el("div", { class: "rule-card" },
+ el("div", { class: "rule-head" },
+ el("span", { class: "rule-num" }, rule.number),
+ el("span", { class: "rule-text" }, rule.text),
+ ),
+ el("div", { class: "kv" },
+ el("span", { class: "k" }, t("suggested_penalty")),
+ el("span", { class: "badge accent" }, rule.suggested_penalty || "—"),
+ ),
+ el("div", { class: "kv" },
+ el("span", { class: "k" }, t("escalation")),
+ escalationViz(rule),
+ ),
+ ) : el("div", { class: "muted" }, p.rule_number ? (p.rule_number + " — " + t("rule_not_found")) : t("none")),
+ el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
+ el("button", { onclick: () => backdrop.remove() }, t("close")),
+ ),
+ ));
+ document.body.appendChild(backdrop);
+ }
+
+ // Apply workflow: select task → step through each pilot's open penalties one
+ // at a time → review overview → save all reviewed as applied.
+ function openApplyByTaskModal() {
+ const wiz = {
+ task: null, // selected task string ("" for empty-task)
+ pilots: [], // [{ pilotNumber, pilotName, penalties: [...] }]
+ cursor: 0, // index into wiz.pilots
+ reviewedIds: [], // penalty IDs the user has stepped past
+ };
+
+ const backdrop = el("div", { class: "modal-backdrop",
+ onclick: (e) => { if (e.target === backdrop) attemptClose(); } });
+ const modal = el("div", { class: "modal" });
+ backdrop.appendChild(modal);
+ document.body.appendChild(backdrop);
+
+ function attemptClose() {
+ if (wiz.reviewedIds.length === 0) {
+ backdrop.remove();
+ return;
+ }
+ const choice = confirm(t("confirm_save_partial", { n: wiz.reviewedIds.length }));
+ if (choice) {
+ savePartial();
+ } else {
+ backdrop.remove();
+ }
+ }
+
+ async function savePartial() {
+ try {
+ await API.applyPenalties(competitionId, { ids: wiz.reviewedIds, applied: true });
+ backdrop.remove();
+ await loadPenalties();
+ patchPenaltyTable();
+ } catch (e) { alert(e.message); }
+ }
+
+ function pilotLookup(num) {
+ return state.pilots.find((p) => p.number === num);
+ }
+
+ function renderTaskSelect() {
+ const groups = {};
+ for (const p of state.penalties) {
+ if (p.transferred) continue;
+ const task = p.task || "";
+ if (!groups[task]) groups[task] = 0;
+ groups[task]++;
+ }
+ const tasks = Object.keys(groups).sort(naturalCompare);
+
+ modal.innerHTML = "";
+ modal.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", alignItems: "center" } },
+ el("h3", { style: { margin: 0 } }, t("apply_by_task")),
+ el("button", { class: "ghost", onclick: attemptClose }, "✕"),
+ ));
+ modal.appendChild(el("p", { class: "muted" }, t("apply_select_task")));
+
+ if (tasks.length === 0) {
+ modal.appendChild(el("div", { class: "muted", style: { padding: "1rem 0" } }, t("no_open_penalties")));
+ modal.appendChild(el("div", { class: "row", style: { justifyContent: "flex-end" } },
+ el("button", { onclick: attemptClose }, t("close")),
+ ));
+ return;
+ }
+
+ const list = el("div");
+ for (const task of tasks) {
+ const label = task === "" ? t("no_task") : task;
+ list.appendChild(el("div", { class: "row apply-row",
+ style: { justifyContent: "space-between", padding: "0.4rem 0", borderBottom: "1px solid var(--border)" } },
+ el("div", null, el("strong", null, label), " ",
+ el("span", { class: "muted" }, t("open") + ": " + groups[task])),
+ el("button", { class: "primary",
+ onclick: () => startWalk(task) }, t("start_apply")),
+ ));
+ }
+ modal.appendChild(list);
+ modal.appendChild(el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
+ el("button", { onclick: attemptClose }, t("close")),
+ ));
+ }
+
+ function startWalk(task) {
+ wiz.task = task;
+ // Group open penalties for this task by pilot, sorted by pilot number.
+ const byPilot = new Map();
+ for (const p of state.penalties) {
+ if (p.transferred) continue;
+ if ((p.task || "") !== task) continue;
+ const key = p.pilot_number || "";
+ if (!byPilot.has(key)) {
+ byPilot.set(key, {
+ pilotNumber: p.pilot_number || "",
+ pilotName: p.pilot_name || "",
+ penalties: [],
+ });
+ }
+ byPilot.get(key).penalties.push(p);
+ }
+ wiz.pilots = Array.from(byPilot.values())
+ .sort((a, b) => naturalCompare(a.pilotNumber, b.pilotNumber));
+ wiz.cursor = 0;
+ wiz.reviewedIds = [];
+ if (wiz.pilots.length === 0) renderOverview();
+ else renderPilotStep();
+ }
+
+ function renderPilotStep() {
+ const entry = wiz.pilots[wiz.cursor];
+ const pilot = pilotLookup(entry.pilotNumber);
+ const stepNum = wiz.cursor + 1;
+ const stepTotal = wiz.pilots.length;
+
+ modal.innerHTML = "";
+ modal.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", alignItems: "center" } },
+ el("h3", { style: { margin: 0 } },
+ t("apply_by_task") + " — ",
+ wiz.task === "" ? t("no_task") : wiz.task,
+ ),
+ el("button", { class: "ghost", onclick: attemptClose }, "✕"),
+ ));
+ modal.appendChild(el("div", { class: "muted", style: { marginBottom: "0.5rem" } },
+ t("step_x_of_y", { x: stepNum, y: stepTotal })));
+
+ modal.appendChild(el("h4", null,
+ t("pilot") + ": ",
+ entry.pilotNumber,
+ entry.pilotName ? " — " + entry.pilotName : "",
+ ));
+ if (pilot) {
+ modal.appendChild(el("div", { class: "kv" }, el("span", { class: "k" }, t("country")),
+ el("span", null, pilot.country || "—")));
+ modal.appendChild(el("div", { class: "kv" }, el("span", { class: "k" }, t("balloon_id")),
+ el("span", null, pilot.balloon_id || "—")));
+ }
+
+ const tbl = el("table", { class: "mini-table", style: { marginTop: "0.5rem" } });
+ tbl.appendChild(el("thead", null, el("tr", null,
+ el("th", null, t("flight")),
+ el("th", null, t("date")),
+ el("th", null, t("rule_number_short")),
+ el("th", null, t("penalty_values")),
+ el("th", null, t("description")),
+ )));
+ const tb = el("tbody");
+ for (const p of entry.penalties) {
+ tb.appendChild(el("tr", null,
+ el("td", null, p.flight || ""),
+ el("td", null, p.date || ""),
+ el("td", null, p.rule_number || ""),
+ el("td", null, el("span", { class: "badge accent" }, p.penalties_text || "")),
+ el("td", null, p.description || ""),
+ ));
+ }
+ tbl.appendChild(tb);
+ modal.appendChild(tbl);
+
+ modal.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginTop: "1rem" } },
+ el("button", { onclick: attemptClose }, t("cancel")),
+ el("div", { class: "row" },
+ wiz.cursor > 0 && el("button", { onclick: () => {
+ // step back: don't remove already-reviewed pilots (they remain
+ // in reviewedIds — going back is just preview).
+ wiz.cursor--;
+ renderPilotStep();
+ } }, "← " + t("back")),
+ el("button", { class: "primary", onclick: () => {
+ for (const p of entry.penalties) {
+ if (!wiz.reviewedIds.includes(p.id)) wiz.reviewedIds.push(p.id);
+ }
+ wiz.cursor++;
+ if (wiz.cursor >= wiz.pilots.length) renderOverview();
+ else renderPilotStep();
+ } }, wiz.cursor + 1 >= wiz.pilots.length ? t("to_overview") + " →" : t("next") + " →"),
+ ),
+ ));
+ }
+
+ function renderOverview() {
+ modal.innerHTML = "";
+ modal.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", alignItems: "center" } },
+ el("h3", { style: { margin: 0 } },
+ t("apply_overview") + " — ",
+ wiz.task === "" ? t("no_task") : wiz.task,
+ ),
+ el("button", { class: "ghost", onclick: attemptClose }, "✕"),
+ ));
+
+ const reviewedSet = new Set(wiz.reviewedIds);
+ const items = [];
+ for (const entry of wiz.pilots) {
+ for (const p of entry.penalties) {
+ if (reviewedSet.has(p.id)) items.push(p);
+ }
+ }
+
+ modal.appendChild(el("p", { class: "muted" },
+ t("apply_overview_explain", { n: items.length })));
+
+ if (items.length === 0) {
+ modal.appendChild(el("div", { class: "muted", style: { padding: "1rem 0" } }, t("nothing_to_apply")));
+ } else {
+ const tbl = el("table", { class: "mini-table" });
+ tbl.appendChild(el("thead", null, el("tr", null,
+ el("th", null, t("pilot_number")),
+ el("th", null, t("pilot_name")),
+ el("th", null, t("flight")),
+ el("th", null, t("rule_number_short")),
+ el("th", null, t("penalty_values")),
+ el("th", null, t("description")),
+ )));
+ const tb = el("tbody");
+ items.sort((a, b) =>
+ naturalCompare(a.pilot_number, b.pilot_number) ||
+ naturalCompare(a.flight, b.flight) ||
+ (a.id - b.id));
+ for (const p of items) {
+ tb.appendChild(el("tr", null,
+ el("td", null, p.pilot_number || ""),
+ el("td", null, p.pilot_name || ""),
+ el("td", null, p.flight || ""),
+ el("td", null, p.rule_number || ""),
+ el("td", null, el("span", { class: "badge accent" }, p.penalties_text || "")),
+ el("td", null, p.description || ""),
+ ));
+ }
+ tbl.appendChild(tb);
+ modal.appendChild(el("div", { class: "table-wrap" }, tbl));
+ }
+
+ modal.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginTop: "1rem" } },
+ el("button", { onclick: () => {
+ // go back to last pilot step (preview)
+ if (wiz.pilots.length > 0) {
+ wiz.cursor = wiz.pilots.length - 1;
+ renderPilotStep();
+ } else {
+ renderTaskSelect();
+ }
+ } }, "← " + t("back")),
+ el("div", { class: "row" },
+ el("button", { onclick: attemptClose }, t("cancel")),
+ el("button", { class: "primary", disabled: items.length === 0, onclick: async (e) => {
+ e.target.disabled = true;
+ try {
+ await API.applyPenalties(competitionId, { ids: wiz.reviewedIds, applied: true });
+ backdrop.remove();
+ await loadPenalties();
+ patchPenaltyTable();
+ } catch (err) { alert(err.message); e.target.disabled = false; }
+ } }, t("save")),
+ ),
+ ));
+ }
+
+ renderTaskSelect();
+ }
+
+ function openPenaltyModal(penalty) {
+ const p = penalty || {};
+ const backdrop = el("div", { class: "modal-backdrop",
+ onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
+
+ 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" });
+ (r.escalation_tiers || []).forEach((tier, i, arr) => {
+ wrap.appendChild(el("span", { class: "tier-pill" }, tier));
+ if (i < arr.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 });
+
+ backdrop.appendChild(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),
+ isChief() && el("label", { class: "row", style: { marginTop: "0.5rem" } }, transferred, " " + t("applied")),
+ el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
+ el("button", { onclick: () => backdrop.remove() }, 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,
+ };
+ if (isChief()) body.transferred = transferred.checked;
+ try {
+ if (p.id) await API.updatePenalty(competitionId, p.id, body);
+ else await API.createPenalty(competitionId, body);
+ backdrop.remove();
+ } catch (e) { alert(e.message); }
+ } }, t("save")),
+ ),
+ ));
+ document.body.appendChild(backdrop);
+ }
+
+ // ---- Pilots tab --------------------------------------------------------
+
+ 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 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");
+ for (const pl of sortPilots(state.pilots)) {
+ 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(competitionId, pl.id);
+ await loadPilots();
+ render();
+ } }, t("delete")),
+ ),
+ ));
+ }
+ table.appendChild(tbody);
+ card.appendChild(el("div", { class: "table-wrap" }, table));
+ return card;
+ }
+
+ function openPilotModal(pilot) {
+ const p = pilot || {};
+ const backdrop = el("div", { class: "modal-backdrop",
+ onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
+ 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 || "" });
+ backdrop.appendChild(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: () => backdrop.remove() }, 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(competitionId, p.id, body);
+ else await API.createPilot(competitionId, body);
+ await loadPilots();
+ backdrop.remove();
+ render();
+ } catch (e) { alert(e.message); }
+ } }, t("save")),
+ ),
+ ));
+ document.body.appendChild(backdrop);
+ }
+
+ function openImportModal() {
+ const backdrop = el("div", { class: "modal-backdrop",
+ onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
+ const textarea = el("textarea", { placeholder: t("csv_paste"), style: { width: "100%", minHeight: "180px" } });
+ backdrop.appendChild(el("div", { class: "modal" },
+ el("h3", null, t("import_csv")),
+ textarea,
+ el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
+ el("button", { onclick: () => backdrop.remove() }, t("cancel")),
+ el("button", { class: "primary", onclick: async () => {
+ try {
+ await API.importPilots(competitionId, textarea.value);
+ await loadPilots();
+ backdrop.remove();
+ render();
+ } catch (e) { alert(e.message); }
+ } }, t("import_csv")),
+ ),
+ ));
+ document.body.appendChild(backdrop);
+ }
+
+ // ---- Rules tab ---------------------------------------------------------
+
+ 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" });
+ (r.escalation_tiers || []).forEach((tier, i, arr) => {
+ wrap.appendChild(el("span", { class: "tier-pill" }, tier));
+ if (i < arr.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;
+ }
+
+ // ---- Members tab -------------------------------------------------------
+
+ function renderMembersTab() {
+ const card = el("div");
+ card.appendChild(el("div", { class: "toolbar" },
+ el("button", { class: "primary", onclick: openMemberModal }, t("add_member")),
+ user.is_system_admin && el("button", { onclick: openUserModalForCompetition }, 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(competitionId, m.user_id);
+ await loadMembers();
+ render();
+ } }, t("remove")),
+ ),
+ ));
+ }
+ table.appendChild(tbody);
+ card.appendChild(el("div", { class: "table-wrap" }, table));
+ return card;
+ }
+
+ function openMemberModal() {
+ const backdrop = el("div", { class: "modal-backdrop",
+ onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
+ const username = el("input", { type: "text", placeholder: t("username"),
+ oninput: (e) => { e.target.value = e.target.value.toLowerCase(); } });
+ const roleSelect = el("select", null,
+ el("option", { value: "scorer" }, t("scorer")),
+ el("option", { value: "chief_scorer" }, t("chief_scorer")),
+ );
+ backdrop.appendChild(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: () => backdrop.remove() }, t("cancel")),
+ el("button", { class: "primary", onclick: async () => {
+ let users = [];
+ try { users = await API.listUsers(); } catch (e) { alert(t("forbidden")); return; }
+ const wanted = username.value.trim().toLowerCase();
+ const u = users.find((x) => x.username === wanted);
+ if (!u) { alert(t("user_not_found")); return; }
+ await API.addMember(competitionId, { user_id: u.id, role: roleSelect.value });
+ await loadMembers();
+ backdrop.remove();
+ render();
+ } }, t("save")),
+ ),
+ ));
+ document.body.appendChild(backdrop);
+ }
+
+ function openUserModalForCompetition() {
+ const backdrop = el("div", { class: "modal-backdrop",
+ onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
+ const username = el("input", { type: "text",
+ oninput: (e) => { e.target.value = e.target.value.toLowerCase(); } });
+ 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 roleSelect = el("select", null,
+ el("option", { value: "scorer" }, t("scorer")),
+ el("option", { value: "chief_scorer" }, t("chief_scorer")),
+ );
+ backdrop.appendChild(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),
+ el("div", { class: "field" }, el("label", null, t("role")), roleSelect),
+ el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
+ el("button", { onclick: () => backdrop.remove() }, 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().toLowerCase(),
+ password: password.value,
+ display_name: displayName.value,
+ language: langSelect.value,
+ is_system_admin: false,
+ });
+ await API.addMember(competitionId, { user_id: created.id, role: roleSelect.value });
+ await loadMembers();
+ backdrop.remove();
+ render();
+ } catch (err) { alert(err.message); }
+ } }, t("create")),
+ ),
+ ));
+ document.body.appendChild(backdrop);
+ }
+
+ // ---- Settings tab ------------------------------------------------------
+
+ 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 () => {
+ try {
+ await API.updateCompetition(competitionId, {
+ name: name.value, allow_any_scorer_edit: allow.checked,
+ });
+ const c = await API.getCompetition(competitionId);
+ state.competition = c;
+ msg.textContent = t("saved");
+ msg.style.display = "block";
+ } catch (e) { alert(e.message); }
+ } }, t("save_settings")),
+ ),
+ );
+ }
+
+ // ---- Main render -------------------------------------------------------
+
+ function render() {
+ clearNode(root);
+ const backBtn = el("button", { class: "ghost",
+ onclick: () => { if (state.ws) { state.ws.close(); state.ws = null; } navigate("competitions"); } },
+ "← " + t("back"));
+ root.appendChild(renderTopbar(user, { extra: 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);
+ }
+
+ function handleWSMessage(msg) {
+ if (msg.type === "penalty_created") {
+ if (!state.penalties.some((p) => p.id === msg.payload.id)) {
+ 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 === "penalties_applied") {
+ loadPenalties().then(() => 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(competitionId).then((c) => { state.competition = c; render(); });
+ }
+ }
+
+ await loadAll();
+ state.ws = openCompetitionWS(competitionId, {
+ onopen: () => { state.wsOnline = true; const e = document.querySelector(".connection-status"); if (e) { e.classList.add("online"); e.classList.remove("offline"); } },
+ onclose: () => { state.wsOnline = false; const e = document.querySelector(".connection-status"); if (e) { e.classList.remove("online"); e.classList.add("offline"); } },
+ onmessage: handleWSMessage,
+ });
+ render();
+})();
diff --git a/web/competitions.html b/web/competitions.html
new file mode 100644
index 0000000..75cb546
--- /dev/null
+++ b/web/competitions.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+Penalty Tracker — Competitions
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/competitions.js b/web/competitions.js
new file mode 100644
index 0000000..ab068c3
--- /dev/null
+++ b/web/competitions.js
@@ -0,0 +1,202 @@
+(async function () {
+ const root = document.getElementById("app");
+ const user = await bootstrapAuth({ requireAuth: true, forbidIfMustChange: true });
+ if (!user) return;
+
+ const state = { competitions: [], users: [] };
+
+ async function loadCompetitions() {
+ state.competitions = await API.listCompetitions();
+ }
+ async function loadUsers() {
+ if (user.is_system_admin) state.users = await API.listUsers();
+ }
+
+ function openCompetitionModal() {
+ const backdrop = el("div", { class: "modal-backdrop",
+ onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
+ const nameInput = el("input", { type: "text" });
+ const allowInput = el("input", { type: "checkbox" });
+ backdrop.appendChild(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: () => backdrop.remove() }, t("cancel")),
+ el("button", { class: "primary", onclick: async () => {
+ if (!nameInput.value.trim()) return;
+ try {
+ await API.createCompetition({ name: nameInput.value.trim(), allow_any_scorer_edit: allowInput.checked });
+ backdrop.remove();
+ await loadCompetitions();
+ render();
+ } catch (e) { alert(e.message); }
+ } }, t("create")),
+ ),
+ ));
+ document.body.appendChild(backdrop);
+ }
+
+ function openUserModal() {
+ const backdrop = el("div", { class: "modal-backdrop",
+ onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
+ const username = el("input", { type: "text",
+ oninput: (e) => { e.target.value = e.target.value.toLowerCase(); } });
+ 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" });
+ backdrop.appendChild(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),
+ el("label", { class: "row" }, isAdmin, " " + t("is_admin")),
+ el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
+ el("button", { onclick: () => backdrop.remove() }, t("cancel")),
+ el("button", { class: "primary", onclick: async () => {
+ if (!username.value.trim() || !password.value) return;
+ try {
+ await API.createUser({
+ username: username.value.trim().toLowerCase(),
+ password: password.value,
+ display_name: displayName.value,
+ language: langSelect.value,
+ is_system_admin: isAdmin.checked,
+ });
+ backdrop.remove();
+ await loadUsers();
+ render();
+ } catch (err) { alert(err.message); }
+ } }, t("create")),
+ ),
+ ));
+ document.body.appendChild(backdrop);
+ }
+
+ 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("must_change_password")),
+ el("th", null, t("actions")),
+ )
+ ));
+ const tbody = el("tbody");
+ for (const u of state.users) {
+ const row = 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.must_change_password ? el("span", { class: "badge warn" }, t("yes")) : t("no")),
+ el("td", null,
+ el("button", { class: "action-btn", onclick: () => openEditUserModal(u) }, t("edit")),
+ !u.must_change_password && el("button", { class: "action-btn", onclick: async () => {
+ if (!confirm(t("confirm_force_password"))) return;
+ await API.updateUser(u.id, { must_change_password: true });
+ await loadUsers();
+ render();
+ } }, t("force_password_change")),
+ u.id !== 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")),
+ ),
+ );
+ tbody.appendChild(row);
+ }
+ table.appendChild(tbody);
+ card.appendChild(el("div", { class: "table-wrap" }, table));
+ return card;
+ }
+
+ function openEditUserModal(u) {
+ const backdrop = el("div", { class: "modal-backdrop",
+ onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
+ const username = el("input", { type: "text", value: u.username,
+ oninput: (e) => { e.target.value = e.target.value.toLowerCase(); } });
+ const displayName = el("input", { type: "text", value: u.display_name || "" });
+ const password = el("input", { type: "password", placeholder: t("leave_blank_keep") });
+ const isAdmin = el("input", { type: "checkbox", checked: !!u.is_system_admin });
+ backdrop.appendChild(el("div", { class: "modal" },
+ el("h3", null, t("edit") + ": " + u.username),
+ 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),
+ u.id !== user.id && el("label", { class: "row" }, isAdmin, " " + t("is_admin")),
+ el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
+ el("button", { onclick: () => backdrop.remove() }, t("cancel")),
+ el("button", { class: "primary", onclick: async () => {
+ const body = {};
+ const newUsername = username.value.trim().toLowerCase();
+ if (newUsername && newUsername !== u.username) body.username = newUsername;
+ if (displayName.value !== (u.display_name || "")) body.display_name = displayName.value;
+ if (password.value) body.password = password.value;
+ if (u.id !== user.id && isAdmin.checked !== !!u.is_system_admin) body.is_system_admin = isAdmin.checked;
+ if (Object.keys(body).length === 0) { backdrop.remove(); return; }
+ try {
+ await API.updateUser(u.id, body);
+ backdrop.remove();
+ await loadUsers();
+ render();
+ } catch (e) { alert((e.data && e.data.error) || e.message); }
+ } }, t("save")),
+ ),
+ ));
+ document.body.appendChild(backdrop);
+ }
+
+ function render() {
+ clearNode(root);
+ root.appendChild(renderTopbar(user));
+ const container = el("div", { class: "container" });
+ container.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.75rem" } },
+ el("h2", { style: { margin: 0 } }, t("competitions")),
+ user.is_system_admin && el("button", { class: "primary", onclick: openCompetitionModal }, t("new_competition")),
+ ));
+
+ if (state.competitions.length === 0) {
+ container.appendChild(el("div", { class: "muted" }, t("no_competitions")));
+ } else {
+ const grid = el("div", { class: "grid" });
+ for (const c of state.competitions) {
+ grid.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: () => navigate("competition", { id: c.id }) }, t("open")),
+ ));
+ }
+ container.appendChild(grid);
+ }
+
+ if (user.is_system_admin) {
+ container.appendChild(renderUsersAdmin());
+ }
+ root.appendChild(container);
+ }
+
+ await loadCompetitions();
+ await loadUsers();
+ render();
+})();
diff --git a/web/force-password.html b/web/force-password.html
new file mode 100644
index 0000000..09d56e5
--- /dev/null
+++ b/web/force-password.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+Penalty Tracker — Change password
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/force-password.js b/web/force-password.js
new file mode 100644
index 0000000..1691da0
--- /dev/null
+++ b/web/force-password.js
@@ -0,0 +1,51 @@
+(async function () {
+ const root = document.getElementById("app");
+ const user = await bootstrapAuth({ requireAuth: true, onlyIfMustChange: true });
+ if (!user) return;
+
+ const pw1 = el("input", { type: "password", autocomplete: "new-password" });
+ const pw2 = el("input", { type: "password", autocomplete: "new-password" });
+ const err = el("div", { class: "muted", style: { color: "var(--danger)", display: "none" } });
+
+ const logoutBtn = el("button", { class: "ghost", onclick: async () => {
+ try { await API.logout(); } catch (_) {}
+ navigate("login");
+ } }, t("logout"));
+
+ async function submit(e) {
+ e.preventDefault();
+ err.style.display = "none";
+ if (pw1.value.length < 6) {
+ err.textContent = t("password_too_short");
+ err.style.display = "block";
+ return;
+ }
+ if (pw1.value !== pw2.value) {
+ err.textContent = t("passwords_dont_match");
+ err.style.display = "block";
+ return;
+ }
+ try {
+ const u = await API.updateMe({ password: pw1.value });
+ if (u && !u.must_change_password) navigate("competitions");
+ } catch (e) {
+ err.textContent = (e.data && e.data.error) || e.message || "error";
+ err.style.display = "block";
+ }
+ }
+
+ const form = el("form", { onsubmit: submit, class: "col" },
+ el("h1", null, t("change_password")),
+ el("p", { class: "muted" }, t("force_password_explain")),
+ el("div", { class: "field" }, el("label", null, t("new_password")), pw1),
+ el("div", { class: "field" }, el("label", null, t("repeat_password")), pw2),
+ err,
+ el("div", { class: "row", style: { justifyContent: "space-between", marginTop: "0.5rem" } },
+ logoutBtn,
+ el("button", { type: "submit", class: "primary" }, t("save")),
+ ),
+ );
+
+ root.appendChild(el("div", { class: "login-wrap" }, el("div", { class: "login-box" }, form)));
+ pw1.focus();
+})();
diff --git a/web/i18n.js b/web/i18n.js
index e0a6a6c..38291b5 100644
--- a/web/i18n.js
+++ b/web/i18n.js
@@ -52,6 +52,49 @@ const I18N_DATA = {
username_taken: "Username already taken",
prior_penalties: "Prior penalties for this pilot and rule",
none: "None",
+ applied: "Applied",
+ apply_by_task: "Apply by task",
+ apply_by_task_explain: "Confirm all open penalties for a task at once. Penalties are only marked applied after confirmation.",
+ apply_n_open: "Apply {n} open",
+ confirm_apply_task: "Mark all {n} open penalties for task '{task}' as applied?",
+ summary: "Summary",
+ penalty_summary: "Penalty summary",
+ rule: "Rule",
+ rule_not_found: "rule not found",
+ close: "Close",
+ count: "#",
+ count_hint: "Number of prior penalties for this pilot and rule",
+ prior_count: "Prior count (this pilot & rule)",
+ search_penalties: "Search penalties…",
+ filter_all: "All",
+ filter_open: "Open only",
+ filter_applied: "Applied only",
+ total: "Total", open: "Open",
+ repeat_password: "Repeat password",
+ password_too_short: "Password must be at least 6 characters",
+ passwords_dont_match: "Passwords do not match",
+ too_many_attempts: "Too many login attempts — please wait a few minutes",
+ profile_username_readonly: "Username can only be changed by a system administrator",
+ profile_displayname_readonly: "Display name can only be changed by a system administrator",
+ must_change_password: "Must change password",
+ confirm_force_password: "Force this user to change their password on next request?",
+ force_password_change: "Force password change",
+ force_password_explain: "An administrator has required you to set a new password before you can continue.",
+ user_not_found: "User not found",
+ show_incidents: "Show incidents",
+ hide_incidents: "Hide incidents",
+ apply_select_task: "Pick a task to start applying its open penalties.",
+ no_open_penalties: "No open penalties to apply.",
+ no_task: "(no task)",
+ start_apply: "Start",
+ step_x_of_y: "Pilot {x} of {y}",
+ pilot: "Pilot",
+ next: "Next",
+ to_overview: "To overview",
+ apply_overview: "Overview",
+ apply_overview_explain: "{n} penalty/penalties will be marked applied on save.",
+ nothing_to_apply: "Nothing to apply.",
+ confirm_save_partial: "Save and apply {n} penalty/penalties reviewed so far?",
},
de: {
login_title: "Anmelden",
@@ -100,6 +143,49 @@ const I18N_DATA = {
username_taken: "Benutzername bereits vergeben",
prior_penalties: "Frühere Strafen für diesen Piloten und diese Regel",
none: "Keine",
+ applied: "Angewendet",
+ apply_by_task: "Pro Task anwenden",
+ apply_by_task_explain: "Bestätige alle offenen Strafen einer Aufgabe gemeinsam. Erst nach Bestätigung gelten die Strafen als angewendet.",
+ apply_n_open: "{n} offene anwenden",
+ confirm_apply_task: "Alle {n} offenen Strafen der Aufgabe '{task}' als angewendet markieren?",
+ summary: "Übersicht",
+ penalty_summary: "Strafen-Übersicht",
+ rule: "Regel",
+ rule_not_found: "Regel nicht gefunden",
+ close: "Schließen",
+ count: "#",
+ count_hint: "Anzahl früherer Strafen für diesen Piloten und diese Regel",
+ prior_count: "Frühere Anzahl (dieser Pilot & Regel)",
+ search_penalties: "Strafen durchsuchen…",
+ filter_all: "Alle",
+ filter_open: "Nur offene",
+ filter_applied: "Nur angewendete",
+ total: "Gesamt", open: "Offen",
+ repeat_password: "Passwort wiederholen",
+ password_too_short: "Passwort muss mindestens 6 Zeichen lang sein",
+ passwords_dont_match: "Passwörter stimmen nicht überein",
+ too_many_attempts: "Zu viele Anmeldeversuche — bitte ein paar Minuten warten",
+ profile_username_readonly: "Der Benutzername kann nur vom Systemadministrator geändert werden",
+ profile_displayname_readonly: "Der Anzeigename kann nur vom Systemadministrator geändert werden",
+ must_change_password: "Passwortwechsel erforderlich",
+ confirm_force_password: "Diesen Benutzer beim nächsten Zugriff zum Passwortwechsel zwingen?",
+ force_password_change: "Passwortwechsel erzwingen",
+ force_password_explain: "Ein Administrator hat festgelegt, dass du ein neues Passwort vergeben musst, bevor du fortfahren kannst.",
+ user_not_found: "Benutzer nicht gefunden",
+ show_incidents: "Vorfälle anzeigen",
+ hide_incidents: "Vorfälle ausblenden",
+ apply_select_task: "Aufgabe wählen, deren offene Strafen angewendet werden sollen.",
+ no_open_penalties: "Keine offenen Strafen zum Anwenden.",
+ no_task: "(ohne Aufgabe)",
+ start_apply: "Start",
+ step_x_of_y: "Pilot {x} von {y}",
+ pilot: "Pilot",
+ next: "Weiter",
+ to_overview: "Zur Übersicht",
+ apply_overview: "Übersicht",
+ apply_overview_explain: "Beim Speichern werden {n} Strafe(n) als angewendet markiert.",
+ nothing_to_apply: "Nichts anzuwenden.",
+ confirm_save_partial: "{n} bisher überprüfte Strafe(n) jetzt speichern und anwenden?",
},
pl: {
login_title: "Zaloguj się",
diff --git a/web/index.html b/web/index.html
index 870049e..827a822 100644
--- a/web/index.html
+++ b/web/index.html
@@ -7,10 +7,19 @@
-
+
-
+
+