Compare commits

...

10 Commits

74 changed files with 659 additions and 44803 deletions

View File

@@ -1 +0,0 @@
/nix/store/cgjr3kj3hs7ngznyws5qfg16c8scpys0-bash-interactive-5.3p9

View File

@@ -1,603 +0,0 @@
# Shared library functions for devenv evaluation
{ inputs }:
rec {
# Helper to get overlays for a given input
getOverlays =
inputName: inputAttrs:
let
lib = inputs.nixpkgs.lib;
in
map
(
overlay:
let
input =
inputs.${inputName} or (throw "No such input `${inputName}` while trying to configure overlays.");
in
input.overlays.${overlay}
or (throw "Input `${inputName}` has no overlay called `${overlay}`. Supported overlays: ${lib.concatStringsSep ", " (builtins.attrNames input.overlays)}")
) inputAttrs.overlays or [ ];
# Main function to create devenv configuration for a specific system with profiles support
# This is the full-featured version used by default.nix
mkDevenvForSystem =
{ version
, is_development_version ? false
, require_version_match ? false
, system
, devenv_root
, git_root ? null
, devenv_dotfile
, devenv_dotfile_path
, devenv_tmpdir
, devenv_runtime
, devenv_state ? null
, devenv_istesting ? false
, devenv_direnvrc_latest_version
, active_profiles ? [ ]
, hostname
, username
, cli_options ? [ ]
, skip_local_src ? false
, secretspec ? null
, devenv_inputs ? { }
, devenv_imports ? [ ]
, impure ? false
, nixpkgs_config ? { }
, lock_fingerprint ? null
, primops ? { }
}:
let
inherit (inputs) nixpkgs;
lib = nixpkgs.lib;
targetSystem = system;
overlays = lib.flatten (lib.mapAttrsToList getOverlays devenv_inputs);
# Helper to create pkgs for a given system with nixpkgs_config
mkPkgsForSystem =
evalSystem:
import nixpkgs {
system = evalSystem;
config = nixpkgs_config // {
# nixpkgs' check-meta.nix natively handles permittedInsecurePackages
# via allowInsecureDefaultPredicate using the full derivation name.
# We must NOT override allowInsecurePredicate here, as lib.getName
# strips the version, causing mismatches with user-provided entries
# like "openssl-1.1.1w".
#
# For unfree packages, nixpkgs does not natively support
# permittedUnfreePackages, so we provide a custom predicate.
allowUnfreePredicate =
if nixpkgs_config.allowUnfree or false then
(_: true)
else if (nixpkgs_config.permittedUnfreePackages or [ ]) != [ ] then
(pkg: builtins.elem (lib.getName pkg) (nixpkgs_config.permittedUnfreePackages or [ ]))
else
(_: false);
} // lib.optionalAttrs ((nixpkgs_config.allowlistedLicenses or [ ]) != [ ]) {
allowlistedLicenses = map (name: lib.licenses.${name}) (nixpkgs_config.allowlistedLicenses or [ ]);
} // lib.optionalAttrs ((nixpkgs_config.blocklistedLicenses or [ ]) != [ ]) {
blocklistedLicenses = map (name: lib.licenses.${name}) (nixpkgs_config.blocklistedLicenses or [ ]);
};
inherit overlays;
};
pkgsBootstrap = mkPkgsForSystem targetSystem;
# Helper to import a path, trying .nix first then /devenv.nix
# Returns a list of modules, including devenv.local.nix when present
tryImport =
resolvedPath: basePath:
if lib.hasSuffix ".nix" basePath then
[ (import resolvedPath) ]
else
let
devenvpath = resolvedPath + "/devenv.nix";
localpath = resolvedPath + "/devenv.local.nix";
in
if builtins.pathExists devenvpath then
[ (import devenvpath) ] ++ lib.optional (builtins.pathExists localpath) (import localpath)
else
throw (basePath + "/devenv.nix file does not exist");
importModule =
path:
if lib.hasPrefix "path:" path then
# path: prefix indicates a local filesystem path - strip it and import directly
let
actualPath = builtins.substring 5 999999 path;
in
tryImport (/. + actualPath) path
else if lib.hasPrefix "/" path then
# Absolute path - import directly (avoids input resolution and NAR hash computation)
tryImport (/. + path) path
else if lib.hasPrefix "./" path then
# Relative paths are relative to devenv_root, not bootstrap directory
let
relPath = builtins.substring 1 255 path;
in
tryImport (/. + devenv_root + relPath) path
else if lib.hasPrefix "../" path then
# Parent relative paths also relative to devenv_root
tryImport (/. + devenv_root + "/${path}") path
else
let
paths = lib.splitString "/" path;
name = builtins.head paths;
input = inputs.${name} or (throw "Unknown input ${name}");
subpath = "/${lib.concatStringsSep "/" (builtins.tail paths)}";
devenvpath = input + subpath;
in
tryImport devenvpath path;
# Common modules shared between main evaluation and cross-system evaluation
mkCommonModules =
evalPkgs:
[
(
{ config, ... }:
{
_module.args.pkgs = evalPkgs.appendOverlays (config.overlays or [ ]);
_module.args.secretspec = secretspec;
_module.args.devenvPrimops = primops;
}
)
(inputs.devenv.modules + /top-level.nix)
(
{ options, ... }:
{
config.devenv = lib.mkMerge [
{
root = devenv_root;
dotfile = devenv_dotfile;
}
(
if builtins.hasAttr "cli" options.devenv then
{
cli.version = version;
cli.isDevelopment = is_development_version;
}
else
{
cliVersion = version;
}
)
(lib.optionalAttrs
(builtins.hasAttr "cli" options.devenv
&& builtins.hasAttr "requireVersionMatch" options.devenv.cli)
{
cli.requireVersionMatch = require_version_match;
}
)
(lib.optionalAttrs (builtins.hasAttr "tmpdir" options.devenv) {
tmpdir = devenv_tmpdir;
})
(lib.optionalAttrs (builtins.hasAttr "isTesting" options.devenv) {
isTesting = devenv_istesting;
})
(lib.optionalAttrs (builtins.hasAttr "runtime" options.devenv) {
runtime = devenv_runtime;
})
(lib.optionalAttrs (builtins.hasAttr "state" options.devenv && devenv_state != null) {
state = lib.mkForce devenv_state;
})
(lib.optionalAttrs (builtins.hasAttr "direnvrcLatestVersion" options.devenv) {
direnvrcLatestVersion = devenv_direnvrc_latest_version;
})
];
}
)
(
{ options, ... }:
{
config = lib.mkMerge [
(lib.optionalAttrs (builtins.hasAttr "git" options) {
git.root = git_root;
})
];
}
)
]
++ (lib.flatten (map importModule devenv_imports))
++ (if !skip_local_src then (importModule (devenv_root + "/devenv.nix")) else [ ])
++ [
(
let
localPath = devenv_root + "/devenv.local.nix";
in
if builtins.pathExists localPath then import localPath else { }
)
cli_options
];
# Phase 1: Base evaluation to extract profile definitions
baseProject = lib.evalModules {
specialArgs = inputs // {
inherit inputs secretspec primops;
};
modules = mkCommonModules pkgsBootstrap;
};
# Phase 2: Extract and apply profiles using extendModules with priority overrides
project =
let
# Build ordered list of profile names: hostname -> user -> manual
manualProfiles = active_profiles;
currentHostname = hostname;
currentUsername = username;
hostnameProfiles = lib.optional
(
currentHostname != null
&& currentHostname != ""
&& builtins.hasAttr currentHostname (baseProject.config.profiles.hostname or { })
) "hostname.${currentHostname}";
userProfiles = lib.optional
(
currentUsername != null
&& currentUsername != ""
&& builtins.hasAttr currentUsername (baseProject.config.profiles.user or { })
) "user.${currentUsername}";
# Ordered list of profiles to activate
orderedProfiles = hostnameProfiles ++ userProfiles ++ manualProfiles;
# Resolve profile extends with cycle detection
resolveProfileExtends =
profileName: visited:
if builtins.elem profileName visited then
throw "Circular dependency detected in profile extends: ${lib.concatStringsSep " -> " visited} -> ${profileName}"
else
let
profile = getProfileConfig profileName;
extends = profile.extends or [ ];
newVisited = visited ++ [ profileName ];
extendedProfiles = lib.flatten (map (name: resolveProfileExtends name newVisited) extends);
in
extendedProfiles ++ [ profileName ];
# Get profile configuration by name from baseProject
getProfileConfig =
profileName:
if lib.hasPrefix "hostname." profileName then
let
name = lib.removePrefix "hostname." profileName;
in
baseProject.config.profiles.hostname.${name}
else if lib.hasPrefix "user." profileName then
let
name = lib.removePrefix "user." profileName;
in
baseProject.config.profiles.user.${name}
else
let
availableProfiles = builtins.attrNames (baseProject.config.profiles or { });
hostnameProfiles = map (n: "hostname.${n}") (
builtins.attrNames (baseProject.config.profiles.hostname or { })
);
userProfiles = map (n: "user.${n}") (builtins.attrNames (baseProject.config.profiles.user or { }));
allAvailableProfiles = availableProfiles ++ hostnameProfiles ++ userProfiles;
in
baseProject.config.profiles.${profileName}
or (throw "Profile '${profileName}' not found. Available profiles: ${lib.concatStringsSep ", " allAvailableProfiles}");
# Fold over ordered profiles to build final list with extends
expandedProfiles = lib.foldl'
(
acc: profileName:
let
allProfileNames = resolveProfileExtends profileName [ ];
in
acc ++ allProfileNames
) [ ]
orderedProfiles;
# Map over expanded profiles and apply priorities
allPrioritizedModules = lib.imap0
(
index: profileName:
let
profilePriority = (lib.modules.defaultOverridePriority - 1) - index;
profileConfig = getProfileConfig profileName;
typeNeedsOverride =
type:
if type == null then
false
else
let
typeName = type.name or type._type or "";
isLeafType = builtins.elem typeName [
"str"
"int"
"bool"
"enum"
"path"
"package"
"float"
"anything"
];
in
if isLeafType then
true
else if typeName == "nullOr" then
let
innerType =
type.elemType
or (if type ? nestedTypes && type.nestedTypes ? elemType then type.nestedTypes.elemType else null);
in
if innerType != null then typeNeedsOverride innerType else false
else
false;
pathNeedsOverride =
optionPath:
let
directOption = lib.attrByPath optionPath null baseProject.options;
in
if directOption != null && lib.isOption directOption then
typeNeedsOverride directOption.type
else if optionPath != [ ] then
let
parentPath = lib.init optionPath;
parentOption = lib.attrByPath parentPath null baseProject.options;
in
if parentOption != null && lib.isOption parentOption then
let
freeformType = parentOption.type.freeformType or parentOption.type.nestedTypes.freeformType or null;
elementType =
if freeformType ? elemType then
freeformType.elemType
else if freeformType ? nestedTypes && freeformType.nestedTypes ? elemType then
freeformType.nestedTypes.elemType
else
freeformType;
in
typeNeedsOverride elementType
else
false
else
false;
applyModuleOverride =
config:
if builtins.isFunction config then
let
wrapper = args: applyOverrideRecursive (config args) [ ];
in
lib.mirrorFunctionArgs config wrapper
else
applyOverrideRecursive config [ ];
applyOverrideRecursive =
config: optionPath:
if lib.isAttrs config && config ? _type then
config
else if lib.isAttrs config then
lib.mapAttrs (name: value: applyOverrideRecursive value (optionPath ++ [ name ])) config
else if pathNeedsOverride optionPath then
lib.mkOverride profilePriority config
else
config;
prioritizedConfig = (
profileConfig.module
// {
imports = lib.map
(
importItem:
importItem
// {
imports = lib.map (nestedImport: applyModuleOverride nestedImport) (importItem.imports or [ ]);
}
)
(profileConfig.module.imports or [ ]);
}
);
in
prioritizedConfig
)
expandedProfiles;
in
if allPrioritizedModules == [ ] then
baseProject
else
baseProject.extendModules { modules = allPrioritizedModules; };
config = project.config;
# Per-container scoped re-evaluation that flips `isBuilding` for the
# container being built. Selecting one container cannot pollute the
# evaluation of any other operation, since each `containerBuilds.<name>`
# is its own `extendModules` scope.
mkContainerBuilds =
evalProject:
lib.genAttrs (lib.attrNames evalProject.config.containers) (
name:
let
scoped = evalProject.extendModules {
modules = [{
container.isBuilding = lib.mkForce true;
containers.${name}.isBuilding = lib.mkForce true;
}];
};
in
scoped.config.containers.${name}
);
containerBuilds = mkContainerBuilds project;
# Apply config overlays to pkgs
pkgs = pkgsBootstrap.appendOverlays (config.overlays or [ ]);
options = pkgs.nixosOptionsDoc {
options = builtins.removeAttrs project.options [ "_module" ];
warningsAreErrors = false;
transformOptions =
let
isDocType =
v:
builtins.elem v [
"literalDocBook"
"literalExpression"
"literalMD"
"mdDoc"
];
in
lib.attrsets.mapAttrs (
_: v:
if v ? _type && isDocType v._type then
v.text
else if v ? _type && v._type == "derivation" then
v.name
else
v
);
};
build =
options: config:
lib.concatMapAttrs
(
name: option:
if lib.isOption option then
let
typeName = option.type.name or "";
in
if
builtins.elem typeName [
"output"
"outputOf"
]
then
{
${name} = config.${name};
}
else
{ }
else if builtins.isAttrs option && !lib.isDerivation option then
let
v = build option config.${name};
in
if v != { } then { ${name} = v; } else { }
else
{ }
)
options;
# Helper to evaluate devenv for a specific system (for cross-compilation, e.g. macOS building Linux containers)
evalForSystem =
evalSystem:
let
evalPkgs = mkPkgsForSystem evalSystem;
evalProject = lib.evalModules {
specialArgs = inputs // {
inherit inputs secretspec primops;
};
modules = mkCommonModules evalPkgs;
};
in
{
config = evalProject.config;
containerBuilds = mkContainerBuilds evalProject;
};
# All supported systems for cross-compilation (lazily evaluated)
allSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
# Generate perSystem entries for all systems (only evaluated when accessed)
perSystemConfigs = lib.genAttrs allSystems (
perSystem:
if perSystem == targetSystem then
{ inherit config containerBuilds; }
else
evalForSystem perSystem
);
in
{
inherit
pkgs
config
options
project
;
bash = pkgs.bash;
shell = config.shell;
optionsJSON = options.optionsJSON;
info = config.info;
ci = config.ciDerivation;
build = build project.options config;
devenv = {
# Backwards compatibility: wrap config in devenv attribute for code expecting devenv.config.*
inherit config containerBuilds;
# perSystem structure for cross-compilation (e.g. macOS building Linux containers)
perSystem = perSystemConfigs;
};
};
# Simplified devenv evaluation for inputs
# This is a lightweight version suitable for evaluating an input's devenv.nix
mkDevenvForInput =
{
# The input to evaluate (must have outPath and sourceInfo)
input
, # All resolved inputs (for specialArgs)
allInputs
, # System to evaluate for
system ? builtins.currentSystem
, # Nixpkgs to use (defaults to allInputs.nixpkgs)
nixpkgs ? allInputs.nixpkgs or (throw "nixpkgs input required")
, # Devenv modules (defaults to allInputs.devenv)
devenv ? allInputs.devenv or (throw "devenv input required")
,
}:
let
devenvPath = input.outPath + "/devenv.nix";
hasDevenv = builtins.pathExists devenvPath;
in
if !hasDevenv then
throw ''
Input does not have a devenv.nix file.
Expected file at: ${devenvPath}
To use this input's devenv configuration, the input must provide a devenv.nix file.
''
else
let
pkgs = import nixpkgs {
inherit system;
config = { };
};
lib = pkgs.lib;
project = lib.evalModules {
specialArgs = allInputs // {
inputs = allInputs;
secretspec = null;
};
modules = [
(
{ config, ... }:
{
_module.args.pkgs = pkgs.appendOverlays (config.overlays or [ ]);
}
)
(devenv.outPath + "/src/modules/top-level.nix")
(import devenvPath)
];
};
in
{
inherit pkgs;
config = project.config;
options = project.options;
inherit project;
};
}

View File

@@ -1,19 +0,0 @@
args@{ system
, # The project root (location of devenv.nix)
devenv_root
, ...
}:
let
inherit
(import ./resolve-lock.nix {
src = devenv_root;
inherit system;
})
inputs
;
bootstrapLib = import ./bootstrapLib.nix { inherit inputs; };
in
bootstrapLib.mkDevenvForSystem args

View File

@@ -1,157 +0,0 @@
# Adapted from https://git.lix.systems/lix-project/flake-compat/src/branch/main/default.nix
{ src
, system ? builtins.currentSystem or "unknown-system"
,
}:
let
lockFilePath = src + "/devenv.lock";
lockFile = builtins.fromJSON (builtins.readFile lockFilePath);
rootSrc = {
lastModified = 0;
lastModifiedDate = formatSecondsSinceEpoch 0;
# *hacker voice*: it's definitely a store path, I promise (actually a
# nixlang path value, likely not pointing at the store).
outPath = src;
};
# Format number of seconds in the Unix epoch as %Y%m%d%H%M%S.
formatSecondsSinceEpoch =
t:
let
rem = x: y: x - x / y * y;
days = t / 86400;
secondsInDay = rem t 86400;
hours = secondsInDay / 3600;
minutes = (rem secondsInDay 3600) / 60;
seconds = rem t 60;
# Courtesy of https://stackoverflow.com/a/32158604.
z = days + 719468;
era = (if z >= 0 then z else z - 146096) / 146097;
doe = z - era * 146097;
yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
y = yoe + era * 400;
doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
mp = (5 * doy + 2) / 153;
d = doy - (153 * mp + 2) / 5 + 1;
m = mp + (if mp < 10 then 3 else -9);
y' = y + (if m <= 2 then 1 else 0);
pad = s: if builtins.stringLength s < 2 then "0" + s else s;
in
"${toString y'}${pad (toString m)}${pad (toString d)}${pad (toString hours)}${pad (toString minutes)}${pad (toString seconds)}";
allNodes = builtins.mapAttrs
(
key: node:
let
sourceInfo =
if key == lockFile.root then
rootSrc
# Path inputs pointing to project root (path = ".") should use rootSrc
# to avoid fetchTree hashing the entire project directory
else if node.locked.type or null == "path" && node.locked.path or null == "." then
rootSrc
else
let
locked = node.locked;
isRelativePath = p: p != null && (builtins.substring 0 2 p == "./" || builtins.substring 0 3 p == "../");
# Resolve relative paths against src
resolvedLocked = locked
// (if locked.type or null == "path" && isRelativePath (locked.path or null)
then { path = toString src + "/${locked.path}"; }
else { })
// (if locked.type or null == "git" && isRelativePath (locked.url or null)
then { url = toString src + "/${locked.url}"; }
else { });
in
builtins.fetchTree (node.info or { } // removeAttrs resolvedLocked [ "dir" ]);
subdir = if key == lockFile.root then "" else node.locked.dir or "";
outPath = sourceInfo + ((if subdir == "" then "" else "/") + subdir);
# Resolve a input spec into a node name. An input spec is
# either a node name, or a 'follows' path from the root
# node.
resolveInput =
inputSpec: if builtins.isList inputSpec then getInputByPath lockFile.root inputSpec else inputSpec;
# Follow an input path (e.g. ["dwarffs" "nixpkgs"]) from the
# root node, returning the final node.
getInputByPath =
nodeName: path:
if path == [ ] then
nodeName
else
getInputByPath
# Since this could be a 'follows' input, call resolveInput.
(resolveInput lockFile.nodes.${nodeName}.inputs.${builtins.head path})
(builtins.tail path);
inputs = builtins.mapAttrs (inputName: inputSpec: allNodes.${resolveInput inputSpec}) (
node.inputs or { }
);
# Only import flake.nix for non-root nodes (root doesn't need it)
flake = if key == lockFile.root then null else import (outPath + "/flake.nix");
outputs = if key == lockFile.root then { } else flake.outputs (inputs // { self = result; });
# Lazy devenv evaluation for this input
devenvEval =
let
bootstrapLib = import ./bootstrapLib.nix { inputs = inputs; };
in
bootstrapLib.mkDevenvForInput {
input = { inherit outPath sourceInfo; };
allInputs = inputs;
inherit system;
};
result =
outputs
// sourceInfo
// {
inherit outPath;
inherit inputs;
inherit outputs;
inherit sourceInfo;
_type = "flake";
devenv = devenvEval;
};
nonFlakeResult = sourceInfo // {
inherit outPath;
inherit inputs;
inherit sourceInfo;
_type = "flake";
devenv = devenvEval;
};
in
if node.flake or true && key != lockFile.root then
assert builtins.isFunction flake.outputs;
result
else
nonFlakeResult
)
lockFile.nodes;
result =
if !(builtins.pathExists lockFilePath) then
throw "${lockFilePath} does not exist"
else if lockFile.version >= 5 && lockFile.version <= 7 then
allNodes.${lockFile.root}
else
throw "lock file '${lockFilePath}' has unsupported version ${toString lockFile.version}";
in
{
inputs = result.inputs or { } // {
self = result;
};
}

View File

@@ -1 +0,0 @@
/nix/store/9dswnx96sj7qpqvah77lx8g25hsl1z1x-devenv-shell

View File

@@ -1 +0,0 @@
/nix/store/gj888l55lxj0brzhkjrdcald7zw7pskj-tasks.json

View File

View File

@@ -1,9 +0,0 @@
/home/user01/Projects/score-system/.devenv/bootstrap/bootstrapLib.nix
/home/user01/Projects/score-system/.devenv/bootstrap/default.nix
/home/user01/Projects/score-system/.devenv/bootstrap/resolve-lock.nix
/home/user01/Projects/score-system/devenv.local.nix
/home/user01/Projects/score-system/devenv.lock
/home/user01/Projects/score-system/devenv.nix
/home/user01/Projects/score-system/devenv.yaml
/home/user01/.config/nixpkgs/overlays
/home/user01/.config/nixpkgs/overlays.nix

View File

@@ -1 +0,0 @@

View File

@@ -1,12 +0,0 @@
let
cfg = {};
getName = pkg: (builtins.parseDrvName (pkg.name or pkg.pname or "")).name;
in cfg // {
allowUnfreePredicate =
if cfg.allowUnfree or false then
(_: true)
else if (cfg.permittedUnfreePackages or []) != [] then
(pkg: builtins.elem (getName pkg) (cfg.permittedUnfreePackages or []))
else
(_: false);
}

View File

@@ -1 +0,0 @@
/nix/store/z7jz33yvsqvfv3qxpy2r9mp3mh0ngcvg-devenv-profile

View File

@@ -1 +0,0 @@
/run/user/1000/devenv-3f21a4e

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,163 +0,0 @@
# Disable history during init so devenv internal commands don't pollute history.
set +o history
# Environment diff helpers (always defined for tracking)
# Environment diff helpers (inspired by direnv)
# Diff is stored in _DEVENV_DIFF env var (not a file) so each shell has its own state
# Uses gzip+base64 encoding for compact storage
# Variables to ignore in diff (shell internals that change dynamically)
__devenv_ignored_var() {
case "$1" in
_*|PWD|OLDPWD|SHLVL|SHELL|SHELLOPTS|BASHOPTS|BASH_*|HISTCMD|HISTFILE)
return 0 ;;
PS1|PS2|PS3|PS4|PROMPT|PROMPT_COMMAND|PROMPT_DIRTRIM)
return 0 ;;
COMP_*|READLINE_*|MAILCHECK|COLUMNS|LINES|RANDOM|SECONDS|LINENO|EPOCHSECONDS|EPOCHREALTIME|SRANDOM)
return 0 ;;
STARSHIP_*|__fish*|DIRENV_*|nix_saved_*)
return 0 ;;
*)
return 1 ;;
esac
}
__devenv_capture_env() {
# Capture exported variables using declare -p for proper escaping
declare -p -x 2>/dev/null | LC_ALL=C sort
}
__devenv_serialize_diff() {
# Serialize diff (stdin) to base64-encoded gzip
gzip -c | base64 -w0
}
__devenv_deserialize_diff() {
# Deserialize diff from base64-encoded gzip to stdout
echo "$1" | base64 -d | gzip -d 2>/dev/null
}
__devenv_compute_diff() {
# Compare before ($1) and current env, return diff via _DEVENV_DIFF env var
local before_file="$1"
# Create temp files
local after_file diff_content
after_file=$(mktemp)
diff_content=$(mktemp)
__devenv_capture_env > "$after_file"
# Build associative arrays for before/after
local -A before_vars after_vars
while IFS= read -r line; do
[[ "$line" != declare\ -x\ * ]] && continue
local vardef="${line#declare -x }"
local var="${vardef%%=*}"
[[ -z "$var" ]] && continue
__devenv_ignored_var "$var" && continue
before_vars["$var"]="$line"
done < "$before_file"
while IFS= read -r line; do
[[ "$line" != declare\ -x\ * ]] && continue
local vardef="${line#declare -x }"
local var="${vardef%%=*}"
[[ -z "$var" ]] && continue
__devenv_ignored_var "$var" && continue
after_vars["$var"]="$line"
done < "$after_file"
# Find PREV entries (vars that were modified or removed)
for var in "${!before_vars[@]}"; do
if [[ "${after_vars[$var]}" != "${before_vars[$var]}" ]]; then
echo "P:${before_vars[$var]}" >> "$diff_content"
fi
done
# Find NEXT entries (vars that were added or modified)
for var in "${!after_vars[@]}"; do
if [[ -z "${before_vars[$var]+x}" ]]; then
echo "N:$var" >> "$diff_content"
elif [[ "${after_vars[$var]}" != "${before_vars[$var]}" ]]; then
echo "N:$var" >> "$diff_content"
fi
done
# Serialize and store in env var
_DEVENV_DIFF=$(__devenv_serialize_diff < "$diff_content")
export _DEVENV_DIFF
rm -f "$after_file" "$diff_content"
}
__devenv_apply_reverse_diff() {
# Reverse the diff: restore PREV values, unset NEXT-only vars
[[ -z "$_DEVENV_DIFF" ]] && return
local -A prev_vars
local diff_content
diff_content=$(__devenv_deserialize_diff "$_DEVENV_DIFF")
# First pass: collect and restore PREV declarations
while IFS= read -r line; do
if [[ "$line" == P:declare\ * ]]; then
local decl="${line#P:}"
local var="${decl#declare -x }"
var="${var%%=*}"
prev_vars["$var"]=1
# Use export instead of evaluating the declare statement directly,
# because declare -x inside a function creates a local variable
# in bash 5.0+.
eval "export ${decl#declare -x }" 2>/dev/null
fi
done <<< "$diff_content"
# Second pass: unset NEXT vars that were not in PREV (added vars)
while IFS= read -r line; do
if [[ "$line" == N:* ]]; then
local var="${line#N:}"
if [[ -z "${prev_vars[$var]+x}" ]]; then
unset "$var"
fi
fi
done <<< "$diff_content"
}
# Capture environment BEFORE sourcing devenv (for diff tracking)
_devenv_before_file=$(mktemp)
__devenv_capture_env > "$_devenv_before_file"
# Source the devenv environment
source "/home/user01/Projects/score-system/.devenv/shell-env.sh"
# Compute and store the initial diff in _DEVENV_DIFF env var
__devenv_compute_diff "$_devenv_before_file"
rm -f "$_devenv_before_file"
unset _devenv_before_file
# Save PATH before zsh init potentially modifies it
export _DEVENV_PATH="$PATH"
# Save original ZDOTDIR so zsh init can restore it
if [ -n "$ZDOTDIR" ]; then
export _DEVENV_REAL_ZDOTDIR="$ZDOTDIR"
fi
# Point ZDOTDIR to our init directory containing our .zshrc
export ZDOTDIR="/home/user01/Projects/score-system/.devenv/zsh"
# Re-enable history before exec
set -o history
# Exec into zsh (resolve via PATH if not absolute, since the devenv
# environment may have added it after this process started)
if [ ! -x "/run/current-system/sw/bin/zsh" ] && ! command -v "/run/current-system/sw/bin/zsh" >/dev/null 2>&1; then
echo "devenv: error: shell '/run/current-system/sw/bin/zsh' not found" >&2
echo "devenv: add zsh to your devenv.nix packages or set SHELL to an absolute path" >&2
exit 1
fi
exec "/run/current-system/sw/bin/zsh" -i
echo "devenv: error: failed to exec into /run/current-system/sw/bin/zsh" >&2
exit 1

View File

@@ -1 +0,0 @@
{"managedFiles":[]}

View File

@@ -1,6 +0,0 @@
devenv:container:copy
devenv:enterShell
devenv:enterTest
devenv:files
devenv:files:cleanup
devenv:processes:server

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
# devenv zsh .zshenv - runs before /etc/zshrc.
# Prepend devenv profile site-functions so the system compinit (often
# called from /etc/zshrc on nix-darwin, Debian, etc.) picks them up.
if [ -n "$DEVENV_PROFILE" ] && [ -d "$DEVENV_PROFILE/share/zsh/site-functions" ]; then
fpath=("$DEVENV_PROFILE/share/zsh/site-functions" $fpath)
fi

View File

@@ -1,200 +0,0 @@
# devenv zsh init - restore ZDOTDIR and source user's .zshrc
if [ -n "$_DEVENV_REAL_ZDOTDIR" ]; then
ZDOTDIR="$_DEVENV_REAL_ZDOTDIR"
unset _DEVENV_REAL_ZDOTDIR
[ -f "$ZDOTDIR/.zshenv" ] && source "$ZDOTDIR/.zshenv"
[ -f "$ZDOTDIR/.zshrc" ] && source "$ZDOTDIR/.zshrc"
else
unset ZDOTDIR
[ -f "$HOME/.zshenv" ] && source "$HOME/.zshenv"
[ -f "$HOME/.zshrc" ] && source "$HOME/.zshrc"
fi
# Restore devenv PATH after user's .zshrc may have modified it
export PATH="$_DEVENV_PATH"
# Set devenv prompt prefix
PROMPT="(devenv) ${PROMPT}"
# Hot-reload hook
autoload -Uz add-zsh-hook
__devenv_reload_apply() {
# Source new environment if a reload is pending
if [ -f "/tmp/devenv-reload-274471.sh" ]; then
# Shell out to bash to handle the env diff (bash syntax)
local reload_output
reload_output=$(bash -c '
# Environment diff helpers (inspired by direnv)
# Diff is stored in _DEVENV_DIFF env var (not a file) so each shell has its own state
# Uses gzip+base64 encoding for compact storage
# Variables to ignore in diff (shell internals that change dynamically)
__devenv_ignored_var() {
case "$1" in
_*|PWD|OLDPWD|SHLVL|SHELL|SHELLOPTS|BASHOPTS|BASH_*|HISTCMD|HISTFILE)
return 0 ;;
PS1|PS2|PS3|PS4|PROMPT|PROMPT_COMMAND|PROMPT_DIRTRIM)
return 0 ;;
COMP_*|READLINE_*|MAILCHECK|COLUMNS|LINES|RANDOM|SECONDS|LINENO|EPOCHSECONDS|EPOCHREALTIME|SRANDOM)
return 0 ;;
STARSHIP_*|__fish*|DIRENV_*|nix_saved_*)
return 0 ;;
*)
return 1 ;;
esac
}
__devenv_capture_env() {
# Capture exported variables using declare -p for proper escaping
declare -p -x 2>/dev/null | LC_ALL=C sort
}
__devenv_serialize_diff() {
# Serialize diff (stdin) to base64-encoded gzip
gzip -c | base64 -w0
}
__devenv_deserialize_diff() {
# Deserialize diff from base64-encoded gzip to stdout
echo "$1" | base64 -d | gzip -d 2>/dev/null
}
__devenv_compute_diff() {
# Compare before ($1) and current env, return diff via _DEVENV_DIFF env var
local before_file="$1"
# Create temp files
local after_file diff_content
after_file=$(mktemp)
diff_content=$(mktemp)
__devenv_capture_env > "$after_file"
# Build associative arrays for before/after
local -A before_vars after_vars
while IFS= read -r line; do
[[ "$line" != declare\ -x\ * ]] && continue
local vardef="${line#declare -x }"
local var="${vardef%%=*}"
[[ -z "$var" ]] && continue
__devenv_ignored_var "$var" && continue
before_vars["$var"]="$line"
done < "$before_file"
while IFS= read -r line; do
[[ "$line" != declare\ -x\ * ]] && continue
local vardef="${line#declare -x }"
local var="${vardef%%=*}"
[[ -z "$var" ]] && continue
__devenv_ignored_var "$var" && continue
after_vars["$var"]="$line"
done < "$after_file"
# Find PREV entries (vars that were modified or removed)
for var in "${!before_vars[@]}"; do
if [[ "${after_vars[$var]}" != "${before_vars[$var]}" ]]; then
echo "P:${before_vars[$var]}" >> "$diff_content"
fi
done
# Find NEXT entries (vars that were added or modified)
for var in "${!after_vars[@]}"; do
if [[ -z "${before_vars[$var]+x}" ]]; then
echo "N:$var" >> "$diff_content"
elif [[ "${after_vars[$var]}" != "${before_vars[$var]}" ]]; then
echo "N:$var" >> "$diff_content"
fi
done
# Serialize and store in env var
_DEVENV_DIFF=$(__devenv_serialize_diff < "$diff_content")
export _DEVENV_DIFF
rm -f "$after_file" "$diff_content"
}
__devenv_apply_reverse_diff() {
# Reverse the diff: restore PREV values, unset NEXT-only vars
[[ -z "$_DEVENV_DIFF" ]] && return
local -A prev_vars
local diff_content
diff_content=$(__devenv_deserialize_diff "$_DEVENV_DIFF")
# First pass: collect and restore PREV declarations
while IFS= read -r line; do
if [[ "$line" == P:declare\ * ]]; then
local decl="${line#P:}"
local var="${decl#declare -x }"
var="${var%%=*}"
prev_vars["$var"]=1
# Use export instead of evaluating the declare statement directly,
# because declare -x inside a function creates a local variable
# in bash 5.0+.
eval "export ${decl#declare -x }" 2>/dev/null
fi
done <<< "$diff_content"
# Second pass: unset NEXT vars that were not in PREV (added vars)
while IFS= read -r line; do
if [[ "$line" == N:* ]]; then
local var="${line#N:}"
if [[ -z "${prev_vars[$var]+x}" ]]; then
unset "$var"
fi
fi
done <<< "$diff_content"
}
# Reverse previous diff
__devenv_apply_reverse_diff
# Capture env before sourcing new devenv
_before=$(mktemp)
__devenv_capture_env > "$_before"
# Source new devenv environment
source "/tmp/devenv-reload-274471.sh"
rm -f "/tmp/devenv-reload-274471.sh"
# Compute new diff
__devenv_compute_diff "$_before"
rm -f "$_before"
# Output current environment for the calling shell to parse
export -p
' 2>/dev/null)
# Apply the environment changes
if [ -n "$reload_output" ]; then
eval "$reload_output"
fi
# Update saved PATH
_DEVENV_PATH="$PATH"
fi
}
__devenv_restore_path() {
# Restore devenv PATH (in case direnv or other tools modified it)
export PATH="$_DEVENV_PATH"
}
__devenv_precmd_hook() {
__devenv_reload_apply
__devenv_restore_path
}
add-zsh-hook precmd __devenv_precmd_hook
# Keybinding for manual reload
__devenv_reload_widget() {
__devenv_reload_apply
zle reset-prompt
}
zle -N __devenv_reload_widget
bindkey "${DEVENV_RELOAD_KEYBIND:-\\e\\C-r}" __devenv_reload_widget

1
.gitignore vendored
View File

@@ -27,3 +27,4 @@ vite.config.ts.timestamp-*
.session
local.db
/.devenv
/.devenv

15
;
View File

@@ -1,15 +0,0 @@
{ pkgs, config, ... }: {
languages.typescript.enable = true;
dotenv.disableHint = true;
packages = with pkgs; [
bun
eslint_d
];
env.DEVSHELL_NAME = "󰏖 devenv/#fab387| Bun/yellow";
processes = {
server = {
ports.http.allocate = 5173;
exec = "bun run dev";
};
};
}

36
TODO.md Normal file
View File

@@ -0,0 +1,36 @@
# UI - Medium priority
player view - all registered events and scores for those events
team view - all registered players and all points gained from them - Do i need this?
# Login stuff - HIGH PRIORITY
disableable register button on the login page for end users
make a way to toggle on and off the register page
protect the register endpoint?? (low prio)
# Ledger - HIGH PRIORITY
ledger view
inserting manually into ledger
# Database - HIGH PRIORITY
make events score based off of highest score instead of avg
load results from the excel sheet i made
export whole contest results to csv
# OPTIONAL (low priority)
chat thing
protect all the endpoints?????? (pain in the ass)
> probably need just in case, seems possible with one endpoint/interface
## Non-seeded data
manual registering of teams
live editing of event/score presets
manual registering of events
manual registering of players
result amendments
## Easy shit
animations for all leaderboards
Ongoing since... or ongoing for... on the main page
displaying averages of loaded scores when there's more than one (easy, low prio)
fix the layout on event view to match the homepage view
fix the marquee animation thing and make it global

View File

@@ -20,8 +20,6 @@ export default defineConfig(
{
languageOptions: { globals: { ...globals.browser, ...globals.node } },
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
@@ -37,8 +35,6 @@ export default defineConfig(
}
},
{
// Override or add rule settings here, such as:
// 'svelte/button-has-type': 'error'
rules: {}
}
);

View File

@@ -28,7 +28,7 @@ function readCSV(filename: string): Record<string, any>[] {
async function seed() {
console.log('Resetting database...');
// Disable foreign keys globally during setup to prevent structural mismatches
// Temporarily disable FK checks during reset
await client.execute('PRAGMA foreign_keys = OFF');
await db.delete(schema.scoreLedger);
@@ -53,30 +53,35 @@ async function seed() {
let passwordHash = await Bun.password.hash('password');
await db
.insert(schema.scorers)
.values({ id: crypto.randomUUID(), username: 'admin', passwordHash: passwordHash });
.values({
id: crypto.randomUUID(),
username: 'admin',
role: 'admin',
passwordHash: passwordHash
});
// --- 1. Teams ---
// Seed teams
const teamsCSV = readCSV('teams.csv');
for (const row of teamsCSV) {
await db.insert(schema.teams).values({ name: row.team_name, color: row.color });
console.log(` → Team: ${row.team_name} (${row.color})`);
}
// --- 2. Divisions ---
// Seed divisions
const divisionsCSV = readCSV('divisions.csv');
for (const row of divisionsCSV) {
await db.insert(schema.divisions).values({ name: row.div_name });
console.log(` → Division: ${row.div_name}`);
}
// --- 2.5 Brackets (Added Section) ---
// Seed brackets
const bracketsCSV = readCSV('brackets.csv');
for (const row of bracketsCSV) {
await db.insert(schema.brackets).values({ name: row.bracket_name });
console.log(` → Bracket: ${row.bracket_name}`);
}
// --- 2.75 resultPresets ---
// Seed result presets
const resultPresetsCSV = readCSV('resultPresets.csv');
for (const row of resultPresetsCSV) {
await db.insert(schema.resultPresets).values({
@@ -91,7 +96,7 @@ async function seed() {
);
}
// --- 3. Scoring Presets ---
// Seed scoring presets
const scoringPresetsCSV = readCSV('scoringPresets.csv');
for (const row of scoringPresetsCSV) {
await db.insert(schema.scoringPresets).values({
@@ -102,19 +107,19 @@ async function seed() {
console.log(` → Preset ${row.preset}: placement ${row.placement} = ${row.points}pts`);
}
// Maps for dynamic relational lookups
// Build lookup maps for relational seeding
const dbTeams = await db.select().from(schema.teams);
const dbDivisions = await db.select().from(schema.divisions);
const dbResults = await db.select().from(schema.resultPresets);
const dbBrackets = await db.select().from(schema.brackets); // Look up newly seeded brackets
const dbBrackets = await db.select().from(schema.brackets);
const teamMap = new Map(dbTeams.map((t) => [t.name, t.id]));
const divisionMap = new Map(dbDivisions.map((d) => [d.name, d.id]));
const divisionNameMap = new Map([...divisionMap.entries()].map(([name, id]) => [id, name]));
const bracketMap = new Map(dbBrackets.map((b) => [b.name, b.id])); // Map names to IDs
const resultPresetMap = new Map(dbResults.map((b) => [b.presetName, b.id])); // Map names to IDs
const bracketMap = new Map(dbBrackets.map((b) => [b.name, b.id]));
const resultPresetMap = new Map(dbResults.map((b) => [b.presetName, b.id]));
// --- 4. Players ---
// Seed players
const playersCSV = readCSV('players.csv');
for (const row of playersCSV) {
const teamId = teamMap.get(row.team);
@@ -132,7 +137,7 @@ async function seed() {
);
}
// --- 5. Event Types ---
// Seed event types
const eventTypesCSV = readCSV('eventTypes.csv');
for (const row of eventTypesCSV) {
const presetId = resultPresetMap.get(row.resultPreset);
@@ -150,7 +155,7 @@ async function seed() {
const dbEventTypes = await db.select().from(schema.eventTypes);
const eventTypeMap = new Map(dbEventTypes.map((et) => [et.name, et.id]));
// --- 6. Registered Events ---
// Seed registered events
const eventNameMap = new Map<string, number>();
const registeredEventsCSV = readCSV('registeredEvents.csv');
@@ -176,11 +181,11 @@ async function seed() {
` → Registered Event [id:${inserted.id}]: ${row.event_type} | ${row.division}, winner: ${teamId}, ${row.winner}`
);
// Map the textual event name (e.g., "100m Sprint") to the generated DB ID
// Map event name|division to the generated event ID
eventNameMap.set(`${row.event_type}|${row.division}`, inserted.id);
}
// --- 7. Registered Players ---
// Seed registered players (linking players to events)
const dbPlayers = await db.select().from(schema.players);
const playerMap = new Map(dbPlayers.map((p) => [`${p.firstName} ${p.lastName}`, p]));
@@ -190,7 +195,6 @@ async function seed() {
const divisionName = divisionNameMap.get(player?.division ?? -1);
const actualEventId = eventNameMap.get(`${row.event_registered}|${divisionName}`);
// Dynamic look up of the bracket row's primary key ID using the CSV text
const bracketId = bracketMap.get(row.bracket);
if (!player) throw new Error(`Player "${row.player_registered}" not found`);
@@ -203,7 +207,7 @@ async function seed() {
await db.insert(schema.registeredPlayers).values({
playerID: player.id,
registeredEventID: actualEventId,
bracket: bracketId, // Using the real relational ID instead of raw value
bracket: bracketId,
placement: row.player_placement || 0
});
console.log(
@@ -211,7 +215,7 @@ async function seed() {
);
}
// Re-enable Foreign Key constraints now that the data is built cleanly
// Re-enable FK checks
await client.execute('PRAGMA foreign_keys = ON');
console.log('\n✅ Seeding complete!');

3
src/app.d.ts vendored
View File

@@ -1,7 +1,6 @@
import type { User, Session } from 'better-auth/minimal';
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
// SvelteKit app type declarations
declare global {
namespace App {
interface Locals { user?: User; session?: Session }

View File

@@ -19,7 +19,7 @@ export const handle: Handle = async ({ event, resolve }) => {
const { session, user } = await validateSessionToken(token);
if (session) {
setSessionTokenCookie(event, token, session.expiresAt); // refresh cookie expiry
setSessionTokenCookie(event, token, session.expiresAt);
} else {
deleteSessionTokenCookie(event);
}

View File

@@ -1 +1 @@
// place files you want to import through the `$lib` alias in this folder.
// Place shared exports accessible via $lib alias here

View File

@@ -34,13 +34,13 @@ export async function validateSessionToken(token: string) {
if (!row) return { session: null, user: null };
// Expired — clean up and reject
// Session expired, delete it
if (Date.now() >= row.session.expiresAt.getTime()) {
await db.delete(sessions).where(eq(sessions.id, sessionId));
return { session: null, user: null };
}
// Sliding expiration: renew if past the halfway mark
// Renew session if past the halfway point
if (Date.now() >= row.session.expiresAt.getTime() - DAY_MS * 15) {
const newExpiresAt = new Date(Date.now() + DAY_MS * 30);
await db.update(sessions).set({ expiresAt: newExpiresAt }).where(eq(sessions.id, sessionId));
@@ -59,7 +59,7 @@ export function setSessionTokenCookie(event: RequestEvent, token: string, expire
expires: expiresAt,
path: '/',
httpOnly: true,
secure: !import.meta.env.DEV, // allow http in local dev
secure: !import.meta.env.DEV,
sameSite: 'lax'
});
}

View File

@@ -1,9 +1,9 @@
import { db } from '$lib/server/db';
import { eq } from 'drizzle-orm';
import { sql, eq, and } from 'drizzle-orm';
import * as schema from '$lib/server/db/schema';
import { globalEmitter } from './globalEmitter';
// For page.server.ts so that it doesnt look weird before loading
// Initial data for page load
export async function getAllInitialInfo() {
return {
teams: await getTeams(),
@@ -11,7 +11,7 @@ export async function getAllInitialInfo() {
};
}
// Get teams object from database
// Fetch teams with optional filter
export async function getTeams(teamId?: number) {
const allTeams = await db
.select()
@@ -32,7 +32,7 @@ export async function getTeams(teamId?: number) {
};
}
// Get all registered events from database
// Fetch registered events with optional filter
export async function getRegisteredEvents(eventId?: number) {
async function getWinnerInfo(teamId: number) {
const teamInfo = await getTeams(teamId);
@@ -74,7 +74,6 @@ export async function startEvent(eventId: number) {
.from(schema.registeredEventsView)
.where(eq(schema.registeredEventsView.eventId, eventId));
let requestedEvent = event[0];
console.log(requestedEvent);
if (requestedEvent.state != 0) {
console.log('not startable');
return false;
@@ -84,37 +83,44 @@ export async function startEvent(eventId: number) {
.set({ state: 1 })
.where(eq(schema.registeredEvents.id, requestedEvent.eventId))
.returning();
console.log(replacedEvent);
globalEmitter.emit('eventUpdate');
return true;
}
}
// Get all players with an event id specified
export async function getAllRegisteredEventPlayers(eventId: number) {
// Fetch all players registered for a specific event
export async function getAllRegisteredEventPlayers(eventId: number, getScores?: boolean) {
const eventPlayers = await db
.select()
.from(schema.registeredEventPlayersView)
// where the registered player is registered for that event
.where(eq(schema.registeredEventPlayersView.eventId, eventId))
.orderBy(
schema.registeredEventPlayersView.bracket,
schema.registeredEventPlayersView.placement,
sql`CASE WHEN ${schema.registeredEventPlayersView.placement} = 0 THEN 999999 ELSE ${schema.registeredEventPlayersView.placement} END ASC`,
schema.registeredEventPlayersView.teamName
);
return {
eventPlayers: eventPlayers.map((players) => ({
// 1. Wrap the map in Promise.all and await it
const resolvedPlayers = await Promise.all(
eventPlayers.map(async (players) => ({
id: players.playerId,
firstName: players.firstName,
lastName: players.lastName,
registeredPlayerId: players.registeredPlayerId,
placement: players.placement,
bracket: players.bracket,
eventId: players.eventId,
eventName: players.eventName,
teamId: players.teamId,
teamName: players.teamName,
teamColor: players.teamColor
teamColor: players.teamColor,
playerScores: getScores == true ? await getPlayerScores(players.playerId, eventId) : undefined
}))
);
// 2. Return the fully resolved data
return {
eventPlayers: resolvedPlayers
};
}
@@ -126,6 +132,43 @@ export async function getAllBrackets() {
};
}
export async function getPlayerInfo(playerId: number) {
const playerInfo = await db.select().from(schema.players).where(eq(schema.players.id, playerId));
const teamInfo = await db
.select()
.from(schema.teams)
.where(eq(schema.teams.id, playerInfo[0].team));
return { ...playerInfo[0], teamInfo: teamInfo[0] };
}
export async function getPlayerScores(playerId: number, eventId?: number) {
const playerRegistrations = await getPlayerRegistrations(playerId, eventId);
let scoresObject: any[] = [];
for (let registration in playerRegistrations) {
let currentReg = playerRegistrations[registration];
let scores = await db
.select()
.from(schema.registeredResults)
.where(eq(schema.registeredResults.registeredPlayerId, currentReg.registeredPlayerId));
scoresObject.push(...scores);
}
return scoresObject;
}
export async function getPlayerRegistrations(playerId: number, eventId?: number) {
const playerRegistrations = await db
.select()
.from(schema.registeredEventPlayersView)
.where(
and(
eq(schema.registeredEventPlayersView.playerId, playerId),
eventId ? eq(schema.registeredEventPlayersView.eventId, eventId) : undefined
)
);
return playerRegistrations;
}
export async function getResultPreset(presetId?: number) {
const resultPresets = await db
.select()
@@ -137,48 +180,37 @@ export async function getResultPreset(presetId?: number) {
};
}
// Moved the function the registeredEvents endpoint
// Just merges the results of the previous 3 functions into a standard format
// Merge events, players, brackets, and presets into a frontend-ready structure
export async function getRegisteredEventsWithPlayers(eventId?: number) {
// Get updated events from database
let newEvents = await getRegisteredEvents(eventId);
let registeredEventList = newEvents['events'];
// Get all possible brackets from the database
let brackets = await getAllBrackets();
// Initilise the final eventList
let fullEventList: any[] = [];
// For every event
for (let registeredEvent in registeredEventList) {
let event = registeredEventList[registeredEvent];
// Get the info about the result preset for the ui
let resultPreset = await getResultPreset(event.resultPreset);
let registeredPlayers = await getAllRegisteredEventPlayers(
event.id,
eventId != undefined ? true : undefined
);
// Get all players for the event
let registeredPlayers = await getAllRegisteredEventPlayers(event.id);
// Map the players into an [] with structure {id: number, name: string, items: any[]}
// So that the players are sorted by bracket for the frontend
// Group players by bracket category for the frontend
const bracketOrder = brackets.brackets.map((category) => {
return {
...category,
// Filter the items that match the current bracket name
items: registeredPlayers.eventPlayers.filter((item) => item.bracket === category.name)
};
});
// append the player info and result preset to the event object
let eventWithPlayers = {
...event,
registeredPlayers: bracketOrder,
...resultPreset
};
// combine all of the events into one array
fullEventList.push(eventWithPlayers);
}
// Send to client
return fullEventList;
}

View File

@@ -9,7 +9,7 @@ export const scorers = sqliteTable('users', {
});
export const sessions = sqliteTable('sessions', {
id: text('id').primaryKey(), // SHA-256 hash of the token, never the raw token
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => scorers.id),
@@ -160,6 +160,7 @@ export const registeredEventPlayersView = sqliteView('registeredEventPlayersView
return qb
.select({
playerId: players.id,
registeredPlayerId: registeredPlayers.id,
firstName: players.firstName,
lastName: players.lastName,
placement: registeredPlayers.placement,

View File

@@ -1,34 +1,77 @@
export async function generateEndpoint(
startFunction?: (enqueue: (data: any) => void) => void | Promise<void | (() => void)>
startFunction?: (enqueue: (data: any) => void) => void | Promise<void | (() => void)>,
request?: Request
) {
let streamController: ReadableStreamDefaultController | null = null;
let cleanupFunction: (() => void) | void = undefined;
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let cleanedUp = false;
const safeCleanup = () => {
if (cleanedUp) return;
cleanedUp = true;
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
if (typeof cleanupFunction === 'function') {
cleanupFunction();
cleanupFunction = undefined;
}
};
const enqueue = (data: any) => {
if (cleanedUp) return;
let transferdata = JSON.stringify(data);
// stringify data and add to controller queue
if (streamController) {
streamController.enqueue(`data: ${transferdata}\n\n`);
} else {
console.log('no controller');
try {
streamController.enqueue(`data: ${transferdata}\n\n`);
} catch {
safeCleanup();
}
}
};
const stream = new ReadableStream({
async start(controller) {
streamController = controller;
heartbeatInterval = setInterval(() => {
try {
streamController!.enqueue(': keepalive\n\n');
} catch {
safeCleanup();
}
}, 30000);
heartbeatInterval.unref();
if (startFunction) {
const result = await startFunction(enqueue);
if (typeof result === 'function') {
cleanupFunction = result;
try {
const result = await startFunction(enqueue);
if (typeof result === 'function') {
cleanupFunction = result;
}
} catch (err) {
safeCleanup();
throw err;
}
}
},
async cancel() {
if (cleanupFunction) cleanupFunction();
safeCleanup();
}
});
if (request?.signal) {
if (request.signal.aborted) {
safeCleanup();
} else {
request.signal.addEventListener('abort', () => safeCleanup());
}
}
return {
response: new Response(stream, {
headers: {

View File

@@ -1,5 +1,4 @@
import { EventEmitter } from 'node:events';
// Main emitter for everything
export const globalEmitter = new EventEmitter();
globalEmitter.setMaxListeners(1000);

View File

@@ -13,33 +13,26 @@
let containerRef = $state<HTMLDivElement | null>(null);
let activeId = $state<string | number | null>(null);
/**
* Public function to scroll a specific row into the center of the viewport.
* Can be called manually by the parent component.
*/
/** Scroll a specific row to the center of the viewport */
export function scrollToId(id: string | number) {
if (!containerRef) return;
const targetRow = containerRef.querySelector(`#row-${id}`) as HTMLElement | null;
if (targetRow) {
// Update local state to highlight the centered row
activeId = id;
// Calculate the midpoint math to center the row
const rowOffsetTop = targetRow.offsetTop;
const rowHeight = targetRow.offsetHeight;
const containerHeight = containerRef.clientHeight;
// Target = Row position - (half of container space) + (half of row height adjustment)
const centerScrollTarget = rowOffsetTop - containerHeight / 2 + rowHeight / 2;
// Using behavior: 'auto' for an instantaneous snap instead of smooth scrolling
containerRef.scrollTo({
top: centerScrollTarget,
behavior: 'auto'
});
}
}
}
</script>
<div bind:this={containerRef} class="table-container" style="max-height: {maxHeight};">

View File

@@ -10,7 +10,7 @@ export const fitText: Action<HTMLElement> = (node) => {
node.style.transformOrigin = 'top left';
node.style.transform = 'none';
// Step 1: fit to height
// Fit font size to container height
let size = 1;
node.style.fontSize = size + 'px';
while (node.scrollHeight <= container!.clientHeight) {
@@ -19,7 +19,7 @@ export const fitText: Action<HTMLElement> = (node) => {
}
node.style.fontSize = size - 1 + 'px';
// Step 2: stretch width to fill container
// Stretch width to fill container
const scaleX = container!.clientWidth / node.scrollWidth;
node.style.transform = `scaleX(${scaleX})`;
}

View File

@@ -0,0 +1,15 @@
// alternative, if role isn't on locals.user
import { eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { scorers } from '$lib/server/db/schema';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
if (!locals.user) return { user: null };
const [row] = await db
.select({ role: scorers.role })
.from(scorers)
.where(eq(scorers.id, locals.user.id));
return { user: { ...locals.user, role: row?.role ?? 'scorer' } };
};

View File

@@ -1,22 +1,41 @@
<script lang="ts">
import './layout.css';
import favicon from '$lib/assets/favicon.svg';
import type { LayoutData } from './$types';
let { children } = $props();
let { children, data }: { children: import('svelte').Snippet; data: LayoutData } = $props();
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
<div class="header goldman flex h-15 w-full">
<a
class="align-text-middle justify-left mx-3 my-1 h-auto content-center rounded-sm border-2 border-solid border-red-500 px-2"
class="align-text-middle justify-left mx-3 my-1 h-auto content-center rounded-sm border-2
border-solid border-red-500 px-2"
href="/">home</a
>
<div class="w-full"></div>
<a
class="align-text-middle justify-right mx-3 my-1 h-auto content-center rounded-sm border-2 border-solid border-red-500 px-2"
href="/login">login</a
>
{#if data.user?.role === 'admin'}
<a
class="align-text-middle justify-right mx-3 my-1 h-auto content-center rounded-sm border-2
border-solid border-red-500 px-2"
href="/ledger">ledger</a
>
{/if}
{#if data.user}
<a
class="align-text-middle justify-right mx-3 my-1 h-auto content-center rounded-sm border-2
border-solid border-red-500 px-2"
href="/login">logout</a
>
{:else}
<a
class="align-text-middle justify-right mx-3 my-1 h-auto content-center rounded-sm border-2
border-solid border-red-500 px-2"
href="/login">login</a
>
{/if}
</div>
{@render children()}

View File

@@ -1,6 +1,6 @@
import { getAllInitialInfo } from '$lib/server/databaseManager';
// Literally only here so that the frontend has the right structure
// Provide initial data for the home page
export const load = async () => {
return await getAllInitialInfo();
};

View File

@@ -14,21 +14,18 @@
let scoreEndpoint: EventSource;
let eventEndpoint: EventSource;
// Ref map for event card DOM nodes
// Map event IDs to their DOM elements for scrolling
let eventRefs = $state<Record<number, HTMLElement>>({});
onMount(() => {
// Get the teams endpoint
// Subscribe to live team score updates via SSE
scoreEndpoint = new EventSource('/api/teams');
// When the endpoint sends something
scoreEndpoint.onmessage = (e) => {
// Parse the json
const teamsData = JSON.parse(e.data);
// If its teams info, then update the teams thing
if (teamsData['teams']) teams = teamsData['teams'];
};
// Basically same for events
// Subscribe to live event updates via SSE
eventEndpoint = new EventSource('/api/registeredEvents');
eventEndpoint.onmessage = (e) => {
eventTable = JSON.parse(e.data);
@@ -50,22 +47,29 @@
return n + (s[(v - 20) % 10] ?? s[v] ?? s[0]);
}
// When focusEventId changes, scroll to and highlight that event card
function sortPlayers(items: any) {
return [...items].sort((a, b) => {
// If a is unranked and b is ranked, move a down
if (a.placement === 0 && b.placement !== 0) return 1;
// If a is ranked and b is unranked, move a up
if (a.placement !== 0 && b.placement === 0) return -1;
// If both are ranked, sort numerically ascending (1st, 2nd, 3rd...)
return a.placement - b.placement;
});
}
// Scroll to and highlight the currently ongoing event
$effect(() => {
if (focusEventId == null) return;
tick().then(() => {
// Get focused element
const el = eventRefs[focusEventId!];
// Scroll it to the top of the box
el.scrollIntoView({ alignToTop: true, behavior: 'instant', container: 'nearest' });
// Wait for that to finish
tick().then(() => {
// Scroll the window back to the top
window.scrollTo(0, 0);
});
});
});
// Svelte action: measures text overflow and drives CSS marquee
// Svelte action that enables CSS marquee when text overflows
function marquee(node: HTMLElement) {
function measure() {
const inner = node.querySelector<HTMLElement>('.marquee-inner');
@@ -88,9 +92,9 @@
<svelte:window onbeforeunload={() => scoreEndpoint?.close()} />
<div class="page">
<!-- ═══════════ LEADERBOARD ═══════════ -->
<!-- LEADERBOARD -->
<section class="leaderboard">
<!-- Winner always full-width -->
<!-- Winner, always full-width -->
{#if leaderboard[0]}
{@const team = leaderboard[0]}
<a
@@ -122,7 +126,7 @@
</a>
{/if}
<!-- Runners-up: stretch to fill row, max 5 wide, 1 col on small -->
<!-- Runners-up, responsive grid -->
{#if leaderboard.length > 1}
<div class="runners-grid" style="--runner-count:{Math.min(leaderboard.length - 1, 5)}">
{#each leaderboard.slice(1) as team, i (team.name)}
@@ -158,7 +162,7 @@
{/if}
</section>
<!-- ═══════════ EVENTS TABLE ═══════════ -->
<!-- EVENTS TABLE -->
<p class="section-label">Events</p>
<section class="events-scroll">
@@ -167,7 +171,9 @@
<div
class="event-card"
class:ongoing-event={event.state == 1}
class:completed-event={event.state == 2}
bind:this={eventRefs[event.id]}
style={event.state == 2 ? `--event-color: ${event.winner.color}` : ''}
>
<div class="event-header">
<a href="/event/{event.id}" class="event-name goldman">{event.name}</a>
@@ -177,7 +183,10 @@
{:else if event.state == 2}
<span
class="event-winner event-status goldman"
style="--winner-color:{event.winner.color}">Won By {event.winner.name}</span
style="--winner-color:{event.winner.color}"
>Won By {event.winner.name} at {new Date(event.completed)
.toLocaleTimeString()
.slice(0, -3)}</span
>
{/if}
</div>
@@ -192,7 +201,7 @@
<span class="brackets-name-text align-text-middle">{bracket.name}</span>
<div class="bracket-vertical-sep"></div>
</div>
{#each bracket.items as player}
{#each sortPlayers(bracket.items) as player}
<div class="player-box" style="--c:{player.teamColor}">
<div class="player-ghost" aria-hidden="true">
<svg viewBox="0 0.1 100 0.6" preserveAspectRatio="none" class="ghost-svg">
@@ -208,10 +217,10 @@
</svg>
</div>
<div class="player-name-wrap" use:marquee>
<span class="marquee-inner">
<a href="/stats/player/{player.id}" class="marquee-inner">
{player.firstName}
{player.lastName}
</span>
</a>
</div>
{#if player.placement !== 0}
<div class="player-placement goldman">{ordinal(player.placement)}</div>

View File

@@ -5,32 +5,25 @@ import * as schema from '$lib/server/db/schema';
import { getRegisteredEvents } from '$lib/server/databaseManager';
export async function POST({ request }: any) {
// Decode body
let responseBody = await request.json();
// If there is no request then dont respond
if (!responseBody) {
return new Error('nuh uh');
} else {
console.log(JSON.stringify(responseBody));
if (responseBody.eventId) {
// Get the event
let eventData = await getRegisteredEvents(responseBody.eventId);
// If the event hasnt started or ended
if (eventData.events[0].state != 1) {
return new Error();
}
let scoringPreset = eventData.events[0].scoringPreset;
console.log(scoringPreset);
// make a new main ledger entry
// Create ledger entry to record this scoring event
let newLedgerEntry = await db
.insert(schema.mainLedger)
.values({ registeredEvent: responseBody.eventId })
.returning();
// get the id so i can use it in the sub ledgers
let ledgerEntryId = newLedgerEntry[0].id;
function getPoints(
@@ -45,41 +38,80 @@ export async function POST({ request }: any) {
);
}
// for every bracket and player
// Accumulate scores per team, then batch insert
const teamScores = new Map<any, number>();
for (let bracket in responseBody.brackets) {
for (let player in responseBody.brackets[bracket].players) {
// variable fun
let currentPlayer = responseBody.brackets[bracket].players[player];
console.log(currentPlayer);
let currentPlayerTeam = currentPlayer.teamId;
let currentPlayerPosition = currentPlayer.position;
// If they put in a score
if (currentPlayerPosition > 0) {
let score = getPoints(scoringPreset, currentPlayerPosition);
// If their score is in the preset and they put in a score
if (currentPlayer.scores.length > 0) {
if (score > 0) {
// put the scores on the board baby
// THIS SHOULDNT BE REFERENCED THIS IS INTENDED
let newScoreLedgerEntry = await db
.insert(schema.scoreLedger)
.values({ ledgerID: ledgerEntryId, teamID: currentPlayerTeam, points: score });
const currentTeamScore = teamScores.get(currentPlayerTeam) || 0;
teamScores.set(currentPlayerTeam, currentTeamScore + score);
}
for (let result in currentPlayer.scores) {
let currentResult = currentPlayer.scores[result];
let newScoreEntry = await db
.insert(schema.registeredResults)
.values({
registeredPlayerId: currentPlayer.registeredPlayerId,
resultIndex: parseInt(result),
result: currentResult
})
.returning();
console.log(newScoreEntry);
}
// Update player placement in the database
let newPlayerPlacement = await db
.update(schema.registeredPlayers)
.set({ placement: currentPlayerPosition })
.where(eq(schema.registeredPlayers.playerID, currentPlayer.id))
.where(eq(schema.registeredPlayers.id, currentPlayer.registeredPlayerId))
.returning();
console.log(newPlayerPlacement);
}
}
}
}
// Batch insert team scores into the ledger
if (teamScores.size > 0) {
const ledgerEntries = Array.from(teamScores.entries()).map(([teamID, points]) => ({
ledgerID: ledgerEntryId,
teamID,
points
}));
let newScores = await db.insert(schema.scoreLedger).values(ledgerEntries).returning();
}
// Determine the winning team from accumulated scores
let highestScore = -1;
let winningTeamId = null;
for (let [teamID, points] of teamScores.entries()) {
if (points > highestScore) {
highestScore = points;
winningTeamId = teamID;
}
}
if (winningTeamId) {
let teamWinnerUpdate = await db
.update(schema.registeredEvents)
.set({ teamWinner: winningTeamId, state: 2, timeCompleted: Date.now() })
.where(eq(schema.registeredEvents.id, responseBody.eventId))
.returning();
}
}
// Update the frontends
globalEmitter.emit('scoreUpdate');
// Return a resonse because
globalEmitter.emit('eventUpdate');
return new Response('coolsies');
}
}

View File

@@ -0,0 +1,12 @@
import { redirect } from '@sveltejs/kit';
import { invalidateSession, deleteSessionTokenCookie } from '$lib/server/auth';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
if (event.locals.session) {
await invalidateSession(event.locals.session.id);
}
deleteSessionTokenCookie(event);
throw redirect(303, '/login');
};

View File

@@ -2,44 +2,32 @@ import { getRegisteredEventsWithPlayers } from '$lib/server/databaseManager';
import { globalEmitter } from '$lib/server/globalEmitter';
import { generateEndpoint } from '$lib/server/endpoint';
export async function GET() {
// Generate stream endpoint
const endpoint = generateEndpoint(async (enqueue) => {
// Get the all the events with the players seperated into brackets
export async function GET({ request }) {
const endpoint = generateEndpoint(async (enqueue) => {
let eventList = async () => {
// Get eventList with structure from database
let newEventList = await getRegisteredEventsWithPlayers();
// send to client
enqueue(newEventList);
};
// Send the eventList to the client when a connection is made
// TODO make it so that this only happens on an initial post request
// Send initial data on connection, then subscribe to updates
eventList();
// When the data changes send an update to the client
globalEmitter.on('eventUpdate', eventList);
// Return cleanup function to remove listener when it closes
return () => {
globalEmitter.off('eventUpdate', eventList);
};
});
}, request);
return (await endpoint).response;
}
export async function POST({ request }: any) {
// Decode body
let responseBody = await request.json();
// If there is no request then dont respond
if (!responseBody) {
return new Response('nuh uh');
} else {
// Get requested event
let eventRequested = responseBody.eventId;
// request eventList from database
let eventList = await getRegisteredEventsWithPlayers(eventRequested);
// return eventList to client
return new Response(JSON.stringify(eventList));
}
}

View File

@@ -2,9 +2,6 @@ import { getAllRegisteredEventPlayers } from '$lib/server/databaseManager';
import { globalEmitter } from '$lib/server/globalEmitter';
import { generateEndpoint } from '$lib/server/endpoint';
// Expose post request
export async function POST({ request }: any) {
// When post request recieved increment testscores by 1
// Return ok so the frontend is happy
return new Response('ok');
}

View File

@@ -2,30 +2,25 @@ import { getTeams } from '$lib/server/databaseManager';
import { globalEmitter } from '$lib/server/globalEmitter';
import { generateEndpoint } from '$lib/server/endpoint';
// Expose post request
export async function POST({ request }: any) {
// When post request recieved increment testscores by 1
globalEmitter.emit('incrementScores');
// Return ok so the frontend is happy
return new Response('ok');
}
export async function GET() {
export async function GET({ request }) {
const endpoint = generateEndpoint(async (enqueue) => {
// Function to grab score from database and add it to message queue
let newScore = async () => {
let newScores = await getTeams();
enqueue(newScores);
};
// Initial Sync
// Send initial team scores, then subscribe to updates
newScore();
globalEmitter.on('scoreUpdate', newScore);
// Simply return the cleanup function here
return () => {
globalEmitter.off('scoreUpdate', newScore);
};
});
}, request);
return (await endpoint).response;
}

View File

@@ -0,0 +1,14 @@
import { eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { scorers } from '$lib/server/db/schema';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) return { user: null };
const [row] = await db
.select({ role: scorers.role })
.from(scorers)
.where(eq(scorers.id, locals.user.id));
return { user: { ...locals.user, role: row?.role ?? 'scorer' } };
};

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import type { PageProps } from './$types';
let { params }: PageProps = $props();
let { params, data }: PageProps = $props();
function ordinal(n: number) {
const s = ['th', 'st', 'nd', 'rd'];
@@ -41,7 +41,22 @@
'Content-type': 'application/json; charset=UTF-8'
}
});
return response.json();
const data = await response.json();
// Sort players: placed first (ascending), unplaced last
if (data && data[0] && data[0].registeredPlayers) {
data[0].registeredPlayers.forEach((bracket: any) => {
bracket.items.sort((a: any, b: any) => {
if (a.placement === 0 && b.placement === 0) return 0;
if (a.placement === 0) return 1;
if (b.placement === 0) return -1;
return a.placement - b.placement;
});
});
}
return data;
}
let eventDataPromise = getEventData();
@@ -60,12 +75,21 @@
<div>loading</div>
{:then eventData}
{@const event = eventData[0]}
{console.log(event)}
<div class="flex justify-center">
<div class="w-full flex-col px-[5vw] text-center">
<div
style:background-color={event.state === 1
? 'color-mix(in srgb, #fe640b 18%, transparent)'
: event.state === 2
? 'color-mix(in srgb, #a6e3a1 18%, transparent)'
: ''}
class="align-text-middle my-7 h-10 w-full rounded-2xl border-2 border-solid border-ctp-surface1"
>
{event.name} - {event.division}
{#if event.state == 1}- ONGOING
{:else if event.state == 2}- FINISHED
{/if}
</div>
{#each event.registeredPlayers as bracket, bi}
{#if bi > 0}
@@ -77,7 +101,7 @@
<div class="bracket-vertical-sep"></div>
</div>
{#each bracket.items as player}
<div class="player-box h-30" style="--c:{player.teamColor}">
<div class="player-box" style="--c:{player.teamColor}">
<div class="player-ghost" aria-hidden="true">
<svg viewBox="0 0.1 100 0.6" preserveAspectRatio="none" class="ghost-svg">
<text
@@ -99,6 +123,13 @@
</div>
{#if player.placement !== 0}
<div class="player-placement goldman">{ordinal(player.placement)}</div>
<div class="resultContainer flex justify-center">
{#each player.playerScores as score}
<div class="mx-5 my-1 rounded border-2" style="border-color:{player.teamColor}">
Run {score.resultIndex + 1}: {score.result}{event.resultPresets[0].unit}
</div>
{/each}
</div>
{:else}
<div class="player-placement-gap"></div>
{/if}
@@ -108,10 +139,23 @@
{/each}
</div>
</div>
<div class="mt-10 flex w-full justify-center">
<a
class="flex justify-center rounded border-2 border-solid border-white bg-ctp-surface2 p-4"
href="/event/scoring/{eventId}">Score This Event</a
>
</div>
{#if data.user}
<div class="mt-10 flex w-full justify-center">
<a
class="flex justify-center rounded border-2 border-solid border-white bg-ctp-surface2 p-4"
href="/event/scoring/{eventId}">Score This Event</a
>
</div>
{/if}
{/await}
<style>
.resultContainer {
flex-direction: column;
}
@media (max-width: 479px) {
.resultContainer {
flex-direction: column;
}
}
</style>

View File

@@ -29,7 +29,7 @@
return { destroy: () => ro.disconnect() };
}
let eventId = params.eventId;
let eventId = parseInt(params.eventId);
let eventEndpoint: EventSource;
type Player = { firstName: string; lastName: string; teamColor: string; [key: string]: any };
@@ -41,7 +41,6 @@
let dropTarget = $state<{ bi: number; pi: number } | null>(null);
let submitStatus = $state<'idle' | 'submitting' | 'done' | 'error'>('idle');
// Drag state
let dragSrc = $state<{ bi: number; pi: number } | null>(null);
let sortByScore = $state(true);
@@ -93,6 +92,17 @@
...bracket,
items: sortByScore
? [...bracket.items].sort((a, b) => {
const scoresA = committedScores[a.id]?.filter((s) => s !== null) ?? [];
const scoresB = committedScores[b.id]?.filter((s) => s !== null) ?? [];
const hasA = scoresA.length > 0;
const hasB = scoresB.length > 0;
// Push unranked players to the bottom, then sort by score
if (!hasA && !hasB) return 0;
if (!hasA) return 1;
if (!hasB) return -1;
const fallback = lowerIsBetter ? Infinity : -Infinity;
const sa = useAverage
? average(committedScores[a.id] ?? [], fallback)
@@ -100,6 +110,7 @@
const sb = useAverage
? average(committedScores[b.id] ?? [], fallback)
: best(committedScores[b.id] ?? [], fallback);
return lowerIsBetter ? sa - sb : sb - sa;
})
: bracket.items
@@ -115,7 +126,7 @@
headers: { 'Content-type': 'application/json; charset=UTF-8' }
});
const data = await response.json();
console.log(data);
console.log(data[0]);
event = data[0];
brackets = data[0].registeredPlayers.map((b: any) => ({
...b,
@@ -144,11 +155,16 @@
loading = false;
eventEndpoint = new EventSource('/api/registeredEvents');
eventEndpoint.onmessage = (e) => {
const data = JSON.parse(e.data)[eventId - 1];
console.log(data);
event = data;
brackets = data.registeredPlayers.map((b: any) => ({
eventEndpoint.onmessage = async (e) => {
const response = await fetch('/api/registeredEvents', {
method: 'POST',
body: JSON.stringify({ eventId }),
headers: { 'Content-type': 'application/json; charset=UTF-8' }
});
const data = await response.json();
console.log('sone', data[0]);
event = data[0];
brackets = data[0].registeredPlayers.map((b: any) => ({
...b,
items: [...b.items]
}));
@@ -238,12 +254,15 @@
<div class="flex justify-center">
<div class="w-full flex-col px-[2vw] text-center">
<div
style:background-color={event.state == 1
? 'color-mix(in srgb, #fe640b 18%, transparent);'
: undefined}
style:background-color={event.state === 1
? 'color-mix(in srgb, #fe640b 18%, transparent)'
: event.state === 2
? 'color-mix(in srgb, #a6e3a1 18%, transparent)'
: ''}
class="align-text-middle my-7 h-10 w-full rounded-2xl border-2 border-solid border-ctp-surface1"
>
{event.name} - {event.division} - scoring {#if event.state == 1}- ONGOING
{:else if event.state == 2}- FINISHED
{/if}
</div>
@@ -252,11 +271,6 @@
>Start event</button
>
{/if}
<!-- <button onclick={() => (sortByScore = !sortByScore)}> -->
<!-- {sortByScore ? 'Sort: Score' : 'Sort: Manual'} -->
<!-- </button> -->
<div class="flex flex-row justify-center">
<div class="flex w-50 min-w-0 flex-col">
<div class="brackets-name text-bold">=</div>
@@ -319,36 +333,45 @@
{player.lastName}
</div>
<div class="result-input-containers flex flex-col">
{#each Array.from({ length: numResults }, (_, i) => i) as run}
<input
type="number"
placeholder="run {run + 1}"
disabled={event.state != 1}
value={pendingScores[player.id]?.[run] ?? ''}
oninput={(e) => {
const current = [
...(pendingScores[player.id] ?? Array(numResults).fill(''))
];
current[run] = e.currentTarget.value;
pendingScores[player.id] = current;
}}
onblur={(e) => {
const val = parseFloat(e.currentTarget.value);
const current = [
...(committedScores[player.id] ?? Array(numResults).fill(null))
];
current[run] = isNaN(val) ? null : val;
committedScores[player.id] = current;
}}
/>
{/each}
{#if event.state == 1}
{#each Array.from({ length: numResults }, (_, i) => i) as run}
<input
type="number"
placeholder="run {run + 1}"
class="text-black"
disabled={event.state != 1}
value={pendingScores[player.id]?.[run] ?? ''}
oninput={(e) => {
const current = [
...(pendingScores[player.id] ?? Array(numResults).fill(''))
];
current[run] = e.currentTarget.value;
pendingScores[player.id] = current;
}}
onblur={(e) => {
const val = parseFloat(e.currentTarget.value);
const current = [
...(committedScores[player.id] ?? Array(numResults).fill(null))
];
current[run] = isNaN(val) ? null : val;
committedScores[player.id] = current;
}}
/>
{/each}
{#if event.resultPresets[0].averageResults == 1}
<div class="text-sm opacity-60">
avg: {avgScore.toFixed(2)}
</div>
{/if}
{:else if player.playerScores.length > 0}
{#each player.playerScores as score}
<div class="border-red border-2 px-2">
{score.result}{event.resultPresets[0].unit}
</div>
{/each}
{/if}
</div>
</div>
{#if event.resultPresets[0].averageResults == 1}
<div class="text-sm opacity-60">
avg: {avgScore.toFixed(2)}
</div>
{/if}
</div>
{/each}
</div>

View File

@@ -31,7 +31,7 @@
font-weight: 400;
}
/* ── Leaderboard ── */
/* Leaderboard */
.leaderboard {
display: flex;
flex-direction: column;
@@ -46,7 +46,7 @@
border: 2px solid var(--c);
color: var(--c);
background: color-mix(in srgb, var(--c) 10%, transparent);
/* Grid stacking: ghost + fg share the same cell */
/* Ghost + foreground stacked in same grid cell */
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
@@ -71,7 +71,7 @@
min-height: 56px;
}
/* Ghost SVG: fill the cell edge-to-edge, text flush to bottom */
/* Ghost SVG fills cell, text sits at bottom */
.score-ghost {
width: 100%;
height: 100%;
@@ -84,7 +84,7 @@
display: block;
}
/* Foreground */
/* Foreground content layer */
.score-fg {
position: relative;
z-index: 1;
@@ -126,14 +126,7 @@
font-size: clamp(26px, 7.5vw, 52px);
}
/*
* Runners-up grid:
* - 1 column on small screens (<480px)
* - 2 columns on medium (480699px)
* - 4 columns on large (≥700px), max 5
* --runner-count drives the actual column count on large screens
* so fewer than 5 teams still fill the whole row.
*/
/* Responsive runners-up: 1 col <480px, 2 cols 480-699px, 4 cols ≥700px */
.runners-grid {
display: grid;
gap: 10px;
@@ -154,8 +147,7 @@
}
@media (min-width: 700px) {
.runners-grid {
/* clamp actual count to 4 on large, but use --runner-count
so e.g. 2 teams still fill 2 equal columns not 2 of 4 */
/* Use --runner-count so fewer teams fill the row evenly */
grid-template-columns: repeat(min(var(--runner-count), 4), 1fr);
}
.winner {
@@ -166,7 +158,7 @@
}
}
/* ── Section label ── */
/* Section label */
.section-label {
font-size: 10px;
letter-spacing: 2.5px;
@@ -176,7 +168,7 @@
margin: 0;
}
/* ── Events scrollable container ── */
/* Events scrollable container */
.events-scroll {
flex: 1;
box-shadow: inset 0px 48px 20px -26px rgba(0, 0, 0, 0.35);
@@ -185,7 +177,7 @@
/* max-height: 900px; */
min-height: 0;
padding: 0 14px 24px;
/* Thin custom scrollbar */
/* Thin scrollbar styling */
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, currentColor 30%, transparent) transparent;
}
@@ -206,7 +198,7 @@
color: var(--winner-color);
}
/* ── Event card ── */
/* Event card */
.event-card {
border-radius: 12px;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
@@ -218,7 +210,13 @@
background-color: color-mix(in srgb, currentColor 18%, transparent);
color: var(--ctp-latte-peach);
}
/* Focus highlight pulse — added/removed by $effect */
.completed-event {
border-color: var(--event-color);
/* If you want a subtle background tint like ongoing events usually have: */
background-color: color-mix(in srgb, var(--event-color) 10%, transparent);
}
/* Pulse animation for focused event card */
.event-card.highlight-pulse {
animation: card-pulse 1.2s ease-out forwards;
}
@@ -254,7 +252,7 @@
opacity: 0.4;
}
/* ── Brackets ── */
/* Brackets */
.brackets {
display: flex;
flex-direction: column;
@@ -271,9 +269,9 @@
flex-wrap: wrap;
}
/* ── Player boxes ── */
/* Player boxes */
.player-box {
flex: 1 1 0; /* equal widths, no min-content bias */
flex: 1 1 0;
max-width: 160px;
position: relative;
overflow: hidden;
@@ -287,11 +285,11 @@
min-height: 52px;
}
/* 1 col on small screens, 4 across on large */
/* Mobile: single column layout */
@media (max-width: 479px) {
.brackets {
flex-direction: row;
align-items: stretch; /* was flex-start — lets columns fill full height */
align-items: stretch;
}
.bracket-sep {
width: 1px;
@@ -326,7 +324,7 @@
@media (min-width: 700px) {
.player-box {
max-width: calc(25% - 6px);
} /* 4 per row */
}
}
.player-ghost {
@@ -345,9 +343,14 @@
.player-name-wrap {
position: relative;
z-index: 1;
text-decoration: none;
overflow: hidden;
white-space: nowrap;
}
.player-name-wrap:hover {
text-decoration: underline;
}
.marquee-inner {
display: inline-block;
font-size: 11px;
@@ -379,5 +382,5 @@
margin-top: 2px;
}
.player-placement-gap {
height: 20px;
height: 50px;
}

View File

@@ -0,0 +1,23 @@
// src/routes/admin/+page.server.ts
import { error, redirect } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { scorers } from '$lib/server/db/schema';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) {
throw redirect(303, '/login');
}
const [row] = await db
.select({ role: scorers.role })
.from(scorers)
.where(eq(scorers.id, locals.user.id));
if (row?.role !== 'admin') {
throw error(403, 'Forbidden');
}
return { user: locals.user };
};

View File

@@ -0,0 +1 @@
<div>some</div>

View File

@@ -4,7 +4,13 @@ import { eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { scorers } from '$lib/server/db/schema';
import { generateSessionToken, createSession, setSessionTokenCookie } from '$lib/server/auth';
import type { Actions } from './$types';
import type { Actions } from '../login/$types';
import type { PageServerLoad } from '../login/$types';
export const load: PageServerLoad = async ({ locals }) => {
return { user: locals.user };
};
export const actions: Actions = {
default: async (event) => {

View File

@@ -1,34 +1,46 @@
<script lang="ts">
import type { ActionData } from './$types';
import type { ActionData, PageData } from './$types';
export let data: PageData;
export let form: ActionData;
</script>
<div class="auth-card">
<h1>Log in</h1>
{#if data.user}
<div class="align-center flex h-full w-full flex-col text-center">
<h1>Already logged in</h1>
<p>You're signed in as <strong>{data.user.username}</strong></p>
<a href="/">Go to home</a>
<form method="POST" action="/api/logout">
<button type="submit">Log out</button>
</form>
</div>
{:else}
<h1>Log in</h1>
<form method="POST">
<label>
Username
<input class="text-black" name="username" type="text" autocomplete="username" required />
</label>
<form method="POST">
<label>
Username
<input class="text-black" name="username" type="text" autocomplete="username" required />
</label>
<label>
Password
<input
class="text-black"
name="password"
type="password"
autocomplete="current-password"
required
/>
</label>
<label>
Password
<input
class="text-black"
name="password"
type="password"
autocomplete="current-password"
required
/>
</label>
{#if form?.message}
<p class="error">{form.message}</p>
{/if}
{#if form?.message}
<p class="error">{form.message}</p>
{/if}
<button type="submit">Log in</button>
</form>
<button type="submit">Log in</button>
</form>
{/if}
<!-- <p class="switch">No account? <a href="/signup">Sign up</a></p> -->
</div>

View File

@@ -4,7 +4,7 @@ import { eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { scorers } from '$lib/server/db/schema';
import { generateSessionToken, createSession, setSessionTokenCookie } from '$lib/server/auth';
import type { Actions } from './$types';
import type { Actions } from '../signup/$types';
export const actions: Actions = {
default: async (event) => {
@@ -21,7 +21,7 @@ export const actions: Actions = {
return fail(400, { message: 'Username already taken' });
}
const passwordHash = await Bun.password.hash(password); // defaults to argon2id
const passwordHash = await Bun.password.hash(password);
const userId = crypto.randomUUID();
await db.insert(scorers).values({ id: userId, username, passwordHash });

View File

@@ -9,12 +9,19 @@
<form method="POST">
<label>
Username
<input name="username" type="text" autocomplete="username" required />
<input name="username" class="text-black" type="text" autocomplete="username" required />
</label>
<label>
Password
<input name="password" type="password" autocomplete="new-password" minlength="8" required />
<input
class="text-black"
name="password"
type="password"
autocomplete="new-password"
minlength="8"
required
/>
<small>At least 8 characters</small>
</label>

View File

@@ -0,0 +1,14 @@
import { getPlayerInfo } from '$lib/server/databaseManager';
import type { PageServerLoad } from './$types';
// export const load: PageServerLoad = async ({ params }) => {
// return {
// data: await getPlayerInfo(parseInt(params.playerId))
// };
// };
export const load: PageServerLoad = async ({ params }) => {
return {
playerInfo: getPlayerInfo(parseInt(params.playerId))
};
};

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
function marquee(node: HTMLElement) {
function measure() {
const inner = node.querySelector<HTMLElement>('.marquee-inner');
if (!inner) return;
const overflow = inner.scrollWidth - node.clientWidth;
if (overflow > 2) {
node.style.setProperty('--scroll-dist', `-${overflow + 6}px`);
inner.classList.add('scrolling');
} else {
inner.classList.remove('scrolling');
}
}
measure();
const ro = new ResizeObserver(measure);
ro.observe(node);
return { destroy: () => ro.disconnect() };
}
</script>
{#await data.playerInfo}
<div>Loading...</div>
{:then playerInfo}
{console.log(playerInfo)}
<div class="mt-5 flex justify-center">
<div
class="player-single-box aspect-square w-full max-w-[95vw] justify-center md:aspect-2/1 lg:aspect-2/1"
style="--c:{playerInfo.teamInfo.color}"
>
<div class="player-ghost" aria-hidden="true">
<svg viewBox="0 0.1 100 0.6" preserveAspectRatio="none" class="ghost-svg">
<text
x="0"
y="0.7"
font-size="1"
dominant-baseline="auto"
textLength="100"
lengthAdjust="spacingAndGlyphs"
font-family="'Black Ops One',system-ui">{playerInfo.firstName}</text
>
</svg>
</div>
<div class=" goldman player-name-wrap text-5xl" use:marquee>
{playerInfo.firstName}
{playerInfo.lastName}
</div>
</div>
</div>
{/await}
<style>
.player-single-box {
flex: 1 1 0;
position: relative;
overflow: hidden;
border-radius: 8px;
border: 1.5px solid var(--c);
color: var(--c);
background: color-mix(in srgb, var(--c) 10%, transparent);
padding: 5px 7px 5px;
display: flex;
flex-direction: column;
}
.player-name-wrap {
position: relative;
z-index: 1;
text-decoration: none;
overflow: hidden;
white-space: nowrap;
}
</style>

View File

@@ -3,13 +3,10 @@ import adapter from '@sveltejs/adapter-auto';
/** @type {import('@sveltejs/kit').Config} */
const config = {
compilerOptions: {
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
// Force runes mode (except in node_modules). Remove when Svelte 6 makes it default.
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
},
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter(),
typescript: {