initial
This commit is contained in:
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
# Virtual Environment
|
||||
csc-checkin.venv/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
502
Scripts/Activate.ps1
Normal file
502
Scripts/Activate.ps1
Normal file
@@ -0,0 +1,502 @@
|
||||
<#
|
||||
.Synopsis
|
||||
Activate a Python virtual environment for the current PowerShell session.
|
||||
|
||||
.Description
|
||||
Pushes the python executable for a virtual environment to the front of the
|
||||
$Env:PATH environment variable and sets the prompt to signify that you are
|
||||
in a Python virtual environment. Makes use of the command line switches as
|
||||
well as the `pyvenv.cfg` file values present in the virtual environment.
|
||||
|
||||
.Parameter VenvDir
|
||||
Path to the directory that contains the virtual environment to activate. The
|
||||
default value for this is the parent of the directory that the Activate.ps1
|
||||
script is located within.
|
||||
|
||||
.Parameter Prompt
|
||||
The prompt prefix to display when this virtual environment is activated. By
|
||||
default, this prompt is the name of the virtual environment folder (VenvDir)
|
||||
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
|
||||
|
||||
.Example
|
||||
Activate.ps1
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -Verbose
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||
and shows extra information about the activation as it executes.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
|
||||
Activates the Python virtual environment located in the specified location.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -Prompt "MyPython"
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||
and prefixes the current prompt with the specified string (surrounded in
|
||||
parentheses) while the virtual environment is active.
|
||||
|
||||
.Notes
|
||||
On Windows, it may be required to enable this Activate.ps1 script by setting the
|
||||
execution policy for the user. You can do this by issuing the following PowerShell
|
||||
command:
|
||||
|
||||
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
|
||||
For more information on Execution Policies:
|
||||
https://go.microsoft.com/fwlink/?LinkID=135170
|
||||
|
||||
#>
|
||||
Param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[String]
|
||||
$VenvDir,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[String]
|
||||
$Prompt
|
||||
)
|
||||
|
||||
<# Function declarations --------------------------------------------------- #>
|
||||
|
||||
<#
|
||||
.Synopsis
|
||||
Remove all shell session elements added by the Activate script, including the
|
||||
addition of the virtual environment's Python executable from the beginning of
|
||||
the PATH variable.
|
||||
|
||||
.Parameter NonDestructive
|
||||
If present, do not remove this function from the global namespace for the
|
||||
session.
|
||||
|
||||
#>
|
||||
function global:deactivate ([switch]$NonDestructive) {
|
||||
# Revert to original values
|
||||
|
||||
# The prior prompt:
|
||||
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
|
||||
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
|
||||
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
|
||||
}
|
||||
|
||||
# The prior PYTHONHOME:
|
||||
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
|
||||
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
|
||||
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
|
||||
}
|
||||
|
||||
# The prior PATH:
|
||||
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
|
||||
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
|
||||
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
|
||||
}
|
||||
|
||||
# Just remove the VIRTUAL_ENV altogether:
|
||||
if (Test-Path -Path Env:VIRTUAL_ENV) {
|
||||
Remove-Item -Path env:VIRTUAL_ENV
|
||||
}
|
||||
|
||||
# Just remove VIRTUAL_ENV_PROMPT altogether.
|
||||
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
|
||||
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
|
||||
}
|
||||
|
||||
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
|
||||
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
|
||||
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
|
||||
}
|
||||
|
||||
# Leave deactivate function in the global namespace if requested:
|
||||
if (-not $NonDestructive) {
|
||||
Remove-Item -Path function:deactivate
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.Description
|
||||
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
|
||||
given folder, and returns them in a map.
|
||||
|
||||
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
|
||||
two strings separated by `=` (with any amount of whitespace surrounding the =)
|
||||
then it is considered a `key = value` line. The left hand string is the key,
|
||||
the right hand is the value.
|
||||
|
||||
If the value starts with a `'` or a `"` then the first and last character is
|
||||
stripped from the value before being captured.
|
||||
|
||||
.Parameter ConfigDir
|
||||
Path to the directory that contains the `pyvenv.cfg` file.
|
||||
#>
|
||||
function Get-PyVenvConfig(
|
||||
[String]
|
||||
$ConfigDir
|
||||
) {
|
||||
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
|
||||
|
||||
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
|
||||
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
|
||||
|
||||
# An empty map will be returned if no config file is found.
|
||||
$pyvenvConfig = @{ }
|
||||
|
||||
if ($pyvenvConfigPath) {
|
||||
|
||||
Write-Verbose "File exists, parse `key = value` lines"
|
||||
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
|
||||
|
||||
$pyvenvConfigContent | ForEach-Object {
|
||||
$keyval = $PSItem -split "\s*=\s*", 2
|
||||
if ($keyval[0] -and $keyval[1]) {
|
||||
$val = $keyval[1]
|
||||
|
||||
# Remove extraneous quotations around a string value.
|
||||
if ("'""".Contains($val.Substring(0, 1))) {
|
||||
$val = $val.Substring(1, $val.Length - 2)
|
||||
}
|
||||
|
||||
$pyvenvConfig[$keyval[0]] = $val
|
||||
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
|
||||
}
|
||||
}
|
||||
}
|
||||
return $pyvenvConfig
|
||||
}
|
||||
|
||||
|
||||
<# Begin Activate script --------------------------------------------------- #>
|
||||
|
||||
# Determine the containing directory of this script
|
||||
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$VenvExecDir = Get-Item -Path $VenvExecPath
|
||||
|
||||
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
|
||||
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
|
||||
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
|
||||
|
||||
# Set values required in priority: CmdLine, ConfigFile, Default
|
||||
# First, get the location of the virtual environment, it might not be
|
||||
# VenvExecDir if specified on the command line.
|
||||
if ($VenvDir) {
|
||||
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
|
||||
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
|
||||
Write-Verbose "VenvDir=$VenvDir"
|
||||
}
|
||||
|
||||
# Next, read the `pyvenv.cfg` file to determine any required value such
|
||||
# as `prompt`.
|
||||
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
|
||||
|
||||
# Next, set the prompt from the command line, or the config file, or
|
||||
# just use the name of the virtual environment folder.
|
||||
if ($Prompt) {
|
||||
Write-Verbose "Prompt specified as argument, using '$Prompt'"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
|
||||
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
|
||||
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
|
||||
$Prompt = $pyvenvCfg['prompt'];
|
||||
}
|
||||
else {
|
||||
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
|
||||
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
|
||||
$Prompt = Split-Path -Path $venvDir -Leaf
|
||||
}
|
||||
}
|
||||
|
||||
Write-Verbose "Prompt = '$Prompt'"
|
||||
Write-Verbose "VenvDir='$VenvDir'"
|
||||
|
||||
# Deactivate any currently active virtual environment, but leave the
|
||||
# deactivate function in place.
|
||||
deactivate -nondestructive
|
||||
|
||||
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
|
||||
# that there is an activated venv.
|
||||
$env:VIRTUAL_ENV = $VenvDir
|
||||
|
||||
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
|
||||
|
||||
Write-Verbose "Setting prompt to '$Prompt'"
|
||||
|
||||
# Set the prompt to include the env name
|
||||
# Make sure _OLD_VIRTUAL_PROMPT is global
|
||||
function global:_OLD_VIRTUAL_PROMPT { "" }
|
||||
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
|
||||
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
|
||||
|
||||
function global:prompt {
|
||||
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
|
||||
_OLD_VIRTUAL_PROMPT
|
||||
}
|
||||
$env:VIRTUAL_ENV_PROMPT = $Prompt
|
||||
}
|
||||
|
||||
# Clear PYTHONHOME
|
||||
if (Test-Path -Path Env:PYTHONHOME) {
|
||||
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
|
||||
Remove-Item -Path Env:PYTHONHOME
|
||||
}
|
||||
|
||||
# Add the venv to the PATH
|
||||
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
|
||||
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
|
||||
|
||||
# SIG # Begin signature block
|
||||
# MIIvIwYJKoZIhvcNAQcCoIIvFDCCLxACAQExDzANBglghkgBZQMEAgEFADB5Bgor
|
||||
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
|
||||
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCBnL745ElCYk8vk
|
||||
# dBtMuQhLeWJ3ZGfzKW4DHCYzAn+QB6CCE8MwggWQMIIDeKADAgECAhAFmxtXno4h
|
||||
# MuI5B72nd3VcMA0GCSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQK
|
||||
# EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNV
|
||||
# BAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDAeFw0xMzA4MDExMjAwMDBaFw0z
|
||||
# ODAxMTUxMjAwMDBaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ
|
||||
# bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0
|
||||
# IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB
|
||||
# AL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3EMB/z
|
||||
# G6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKyunWZ
|
||||
# anMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsFxl7s
|
||||
# Wxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU15zHL
|
||||
# 2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJBMtfb
|
||||
# BHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObURWBf3
|
||||
# JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6nj3c
|
||||
# AORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxBYKqx
|
||||
# YxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5SUUd0
|
||||
# viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+xq4aL
|
||||
# T8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjQjBAMA8GA1Ud
|
||||
# EwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTs1+OC0nFdZEzf
|
||||
# Lmc/57qYrhwPTzANBgkqhkiG9w0BAQwFAAOCAgEAu2HZfalsvhfEkRvDoaIAjeNk
|
||||
# aA9Wz3eucPn9mkqZucl4XAwMX+TmFClWCzZJXURj4K2clhhmGyMNPXnpbWvWVPjS
|
||||
# PMFDQK4dUPVS/JA7u5iZaWvHwaeoaKQn3J35J64whbn2Z006Po9ZOSJTROvIXQPK
|
||||
# 7VB6fWIhCoDIc2bRoAVgX+iltKevqPdtNZx8WorWojiZ83iL9E3SIAveBO6Mm0eB
|
||||
# cg3AFDLvMFkuruBx8lbkapdvklBtlo1oepqyNhR6BvIkuQkRUNcIsbiJeoQjYUIp
|
||||
# 5aPNoiBB19GcZNnqJqGLFNdMGbJQQXE9P01wI4YMStyB0swylIQNCAmXHE/A7msg
|
||||
# dDDS4Dk0EIUhFQEI6FUy3nFJ2SgXUE3mvk3RdazQyvtBuEOlqtPDBURPLDab4vri
|
||||
# RbgjU2wGb2dVf0a1TD9uKFp5JtKkqGKX0h7i7UqLvBv9R0oN32dmfrJbQdA75PQ7
|
||||
# 9ARj6e/CVABRoIoqyc54zNXqhwQYs86vSYiv85KZtrPmYQ/ShQDnUBrkG5WdGaG5
|
||||
# nLGbsQAe79APT0JsyQq87kP6OnGlyE0mpTX9iV28hWIdMtKgK1TtmlfB2/oQzxm3
|
||||
# i0objwG2J5VT6LaJbVu8aNQj6ItRolb58KaAoNYes7wPD1N1KarqE3fk3oyBIa0H
|
||||
# EEcRrYc9B9F1vM/zZn4wggawMIIEmKADAgECAhAIrUCyYNKcTJ9ezam9k67ZMA0G
|
||||
# CSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ
|
||||
# bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0
|
||||
# IFRydXN0ZWQgUm9vdCBHNDAeFw0yMTA0MjkwMDAwMDBaFw0zNjA0MjgyMzU5NTla
|
||||
# MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE
|
||||
# AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz
|
||||
# ODQgMjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDVtC9C
|
||||
# 0CiteLdd1TlZG7GIQvUzjOs9gZdwxbvEhSYwn6SOaNhc9es0JAfhS0/TeEP0F9ce
|
||||
# 2vnS1WcaUk8OoVf8iJnBkcyBAz5NcCRks43iCH00fUyAVxJrQ5qZ8sU7H/Lvy0da
|
||||
# E6ZMswEgJfMQ04uy+wjwiuCdCcBlp/qYgEk1hz1RGeiQIXhFLqGfLOEYwhrMxe6T
|
||||
# SXBCMo/7xuoc82VokaJNTIIRSFJo3hC9FFdd6BgTZcV/sk+FLEikVoQ11vkunKoA
|
||||
# FdE3/hoGlMJ8yOobMubKwvSnowMOdKWvObarYBLj6Na59zHh3K3kGKDYwSNHR7Oh
|
||||
# D26jq22YBoMbt2pnLdK9RBqSEIGPsDsJ18ebMlrC/2pgVItJwZPt4bRc4G/rJvmM
|
||||
# 1bL5OBDm6s6R9b7T+2+TYTRcvJNFKIM2KmYoX7BzzosmJQayg9Rc9hUZTO1i4F4z
|
||||
# 8ujo7AqnsAMrkbI2eb73rQgedaZlzLvjSFDzd5Ea/ttQokbIYViY9XwCFjyDKK05
|
||||
# huzUtw1T0PhH5nUwjewwk3YUpltLXXRhTT8SkXbev1jLchApQfDVxW0mdmgRQRNY
|
||||
# mtwmKwH0iU1Z23jPgUo+QEdfyYFQc4UQIyFZYIpkVMHMIRroOBl8ZhzNeDhFMJlP
|
||||
# /2NPTLuqDQhTQXxYPUez+rbsjDIJAsxsPAxWEQIDAQABo4IBWTCCAVUwEgYDVR0T
|
||||
# AQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHwYD
|
||||
# VR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMG
|
||||
# A1UdJQQMMAoGCCsGAQUFBwMDMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYY
|
||||
# aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2Fj
|
||||
# ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNV
|
||||
# HR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRU
|
||||
# cnVzdGVkUm9vdEc0LmNybDAcBgNVHSAEFTATMAcGBWeBDAEDMAgGBmeBDAEEATAN
|
||||
# BgkqhkiG9w0BAQwFAAOCAgEAOiNEPY0Idu6PvDqZ01bgAhql+Eg08yy25nRm95Ry
|
||||
# sQDKr2wwJxMSnpBEn0v9nqN8JtU3vDpdSG2V1T9J9Ce7FoFFUP2cvbaF4HZ+N3HL
|
||||
# IvdaqpDP9ZNq4+sg0dVQeYiaiorBtr2hSBh+3NiAGhEZGM1hmYFW9snjdufE5Btf
|
||||
# Q/g+lP92OT2e1JnPSt0o618moZVYSNUa/tcnP/2Q0XaG3RywYFzzDaju4ImhvTnh
|
||||
# OE7abrs2nfvlIVNaw8rpavGiPttDuDPITzgUkpn13c5UbdldAhQfQDN8A+KVssIh
|
||||
# dXNSy0bYxDQcoqVLjc1vdjcshT8azibpGL6QB7BDf5WIIIJw8MzK7/0pNVwfiThV
|
||||
# 9zeKiwmhywvpMRr/LhlcOXHhvpynCgbWJme3kuZOX956rEnPLqR0kq3bPKSchh/j
|
||||
# wVYbKyP/j7XqiHtwa+aguv06P0WmxOgWkVKLQcBIhEuWTatEQOON8BUozu3xGFYH
|
||||
# Ki8QxAwIZDwzj64ojDzLj4gLDb879M4ee47vtevLt/B3E+bnKD+sEq6lLyJsQfmC
|
||||
# XBVmzGwOysWGw/YmMwwHS6DTBwJqakAwSEs0qFEgu60bhQjiWQ1tygVQK+pKHJ6l
|
||||
# /aCnHwZ05/LWUpD9r4VIIflXO7ScA+2GRfS0YW6/aOImYIbqyK+p/pQd52MbOoZW
|
||||
# eE4wggd3MIIFX6ADAgECAhAHHxQbizANJfMU6yMM0NHdMA0GCSqGSIb3DQEBCwUA
|
||||
# MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE
|
||||
# AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz
|
||||
# ODQgMjAyMSBDQTEwHhcNMjIwMTE3MDAwMDAwWhcNMjUwMTE1MjM1OTU5WjB8MQsw
|
||||
# CQYDVQQGEwJVUzEPMA0GA1UECBMGT3JlZ29uMRIwEAYDVQQHEwlCZWF2ZXJ0b24x
|
||||
# IzAhBgNVBAoTGlB5dGhvbiBTb2Z0d2FyZSBGb3VuZGF0aW9uMSMwIQYDVQQDExpQ
|
||||
# eXRob24gU29mdHdhcmUgRm91bmRhdGlvbjCCAiIwDQYJKoZIhvcNAQEBBQADggIP
|
||||
# ADCCAgoCggIBAKgc0BTT+iKbtK6f2mr9pNMUTcAJxKdsuOiSYgDFfwhjQy89koM7
|
||||
# uP+QV/gwx8MzEt3c9tLJvDccVWQ8H7mVsk/K+X+IufBLCgUi0GGAZUegEAeRlSXx
|
||||
# xhYScr818ma8EvGIZdiSOhqjYc4KnfgfIS4RLtZSrDFG2tN16yS8skFa3IHyvWdb
|
||||
# D9PvZ4iYNAS4pjYDRjT/9uzPZ4Pan+53xZIcDgjiTwOh8VGuppxcia6a7xCyKoOA
|
||||
# GjvCyQsj5223v1/Ig7Dp9mGI+nh1E3IwmyTIIuVHyK6Lqu352diDY+iCMpk9Zanm
|
||||
# SjmB+GMVs+H/gOiofjjtf6oz0ki3rb7sQ8fTnonIL9dyGTJ0ZFYKeb6BLA66d2GA
|
||||
# LwxZhLe5WH4Np9HcyXHACkppsE6ynYjTOd7+jN1PRJahN1oERzTzEiV6nCO1M3U1
|
||||
# HbPTGyq52IMFSBM2/07WTJSbOeXjvYR7aUxK9/ZkJiacl2iZI7IWe7JKhHohqKuc
|
||||
# eQNyOzxTakLcRkzynvIrk33R9YVqtB4L6wtFxhUjvDnQg16xot2KVPdfyPAWd81w
|
||||
# tZADmrUtsZ9qG79x1hBdyOl4vUtVPECuyhCxaw+faVjumapPUnwo8ygflJJ74J+B
|
||||
# Yxf6UuD7m8yzsfXWkdv52DjL74TxzuFTLHPyARWCSCAbzn3ZIly+qIqDAgMBAAGj
|
||||
# ggIGMIICAjAfBgNVHSMEGDAWgBRoN+Drtjv4XxGG+/5hewiIZfROQjAdBgNVHQ4E
|
||||
# FgQUt/1Teh2XDuUj2WW3siYWJgkZHA8wDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQM
|
||||
# MAoGCCsGAQUFBwMDMIG1BgNVHR8Ega0wgaowU6BRoE+GTWh0dHA6Ly9jcmwzLmRp
|
||||
# Z2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNENvZGVTaWduaW5nUlNBNDA5NlNI
|
||||
# QTM4NDIwMjFDQTEuY3JsMFOgUaBPhk1odHRwOi8vY3JsNC5kaWdpY2VydC5jb20v
|
||||
# RGlnaUNlcnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JTQTQwOTZTSEEzODQyMDIxQ0Ex
|
||||
# LmNybDA+BgNVHSAENzA1MDMGBmeBDAEEATApMCcGCCsGAQUFBwIBFhtodHRwOi8v
|
||||
# d3d3LmRpZ2ljZXJ0LmNvbS9DUFMwgZQGCCsGAQUFBwEBBIGHMIGEMCQGCCsGAQUF
|
||||
# BzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wXAYIKwYBBQUHMAKGUGh0dHA6
|
||||
# Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNENvZGVTaWdu
|
||||
# aW5nUlNBNDA5NlNIQTM4NDIwMjFDQTEuY3J0MAwGA1UdEwEB/wQCMAAwDQYJKoZI
|
||||
# hvcNAQELBQADggIBABxv4AeV/5ltkELHSC63fXAFYS5tadcWTiNc2rskrNLrfH1N
|
||||
# s0vgSZFoQxYBFKI159E8oQQ1SKbTEubZ/B9kmHPhprHya08+VVzxC88pOEvz68nA
|
||||
# 82oEM09584aILqYmj8Pj7h/kmZNzuEL7WiwFa/U1hX+XiWfLIJQsAHBla0i7QRF2
|
||||
# de8/VSF0XXFa2kBQ6aiTsiLyKPNbaNtbcucaUdn6vVUS5izWOXM95BSkFSKdE45O
|
||||
# q3FForNJXjBvSCpwcP36WklaHL+aHu1upIhCTUkzTHMh8b86WmjRUqbrnvdyR2yd
|
||||
# I5l1OqcMBjkpPpIV6wcc+KY/RH2xvVuuoHjlUjwq2bHiNoX+W1scCpnA8YTs2d50
|
||||
# jDHUgwUo+ciwpffH0Riq132NFmrH3r67VaN3TuBxjI8SIZM58WEDkbeoriDk3hxU
|
||||
# 8ZWV7b8AW6oyVBGfM06UgkfMb58h+tJPrFx8VI/WLq1dTqMfZOm5cuclMnUHs2uq
|
||||
# rRNtnV8UfidPBL4ZHkTcClQbCoz0UbLhkiDvIS00Dn+BBcxw/TKqVL4Oaz3bkMSs
|
||||
# M46LciTeucHY9ExRVt3zy7i149sd+F4QozPqn7FrSVHXmem3r7bjyHTxOgqxRCVa
|
||||
# 18Vtx7P/8bYSBeS+WHCKcliFCecspusCDSlnRUjZwyPdP0VHxaZg2unjHY3rMYIa
|
||||
# tjCCGrICAQEwfTBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIElu
|
||||
# Yy4xQTA/BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgQ29kZSBTaWduaW5nIFJT
|
||||
# QTQwOTYgU0hBMzg0IDIwMjEgQ0ExAhAHHxQbizANJfMU6yMM0NHdMA0GCWCGSAFl
|
||||
# AwQCAQUAoIHIMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3AgEEMBwGCisGAQQBgjcC
|
||||
# AQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEiBCBnAZ6P7YvTwq0fbF62
|
||||
# o7E75R0LxsW5OtyYiFESQckLhjBcBgorBgEEAYI3AgEMMU4wTKBGgEQAQgB1AGkA
|
||||
# bAB0ADoAIABSAGUAbABlAGEAcwBlAF8AdgAzAC4AMQAyAC4ANABfADIAMAAyADQA
|
||||
# MAA2ADAANgAuADAAMaECgAAwDQYJKoZIhvcNAQEBBQAEggIAV29hYhi09QNyGtav
|
||||
# HZIo33y/iqXsIa4o88S5gzBa7Nnkwra0QLitSjvRfVbcFvq54Id+VIn00di4Nde0
|
||||
# maAUKPGXtTQL48esG/F/TLDOWd/jb9qCYHyNZYpJjKdXqI8IbyG6Pl05IMSas7wX
|
||||
# DHsK19ZEGuGrmKCAxh6JbFXADgeUbftg3i9UxpMnfSugZjjdKIdyVWlzUnpYkKuI
|
||||
# fpafwvNHfIYzfxOeV9CWsdqe34D6fRrEs8ZDEZSQl+Mw9aGaT39vuryFE1iKOzj0
|
||||
# uqrX/wN/wwu8oLWNC7JWE8SDG3eD0QLy+x7zEnlPkWsRV9nGOgrP9Khge0LgL+jP
|
||||
# Km8iDs7fSGEOB/7PPxAl8yshEULOZAhBhcsGeGs+kQrVzlqZ9WlrU1Z1cylpLWzX
|
||||
# Kkvs2DXD+zrplhpiVv6Gnn3YMBr4BKf0mXESTX9/BzIwvxlkhpv/BT0OWwrDlgPM
|
||||
# hNj8jA5r2/WSqCg15DYjJ0RlnCerC/ORhSbs7v/HjpmH3DhaICJF7tdyFSIFXgNV
|
||||
# W0GyQJMulQDEPd2+o+PNyAPElvGC3SYTjVnRLPcJTGhAt+VuHfnMG4HNkmyeU+nk
|
||||
# OAMShxEax6NLeRsjKqqABUgZb2g4FSmXzHy7HgQOPmCQMv8xH4m8u992YMLyxh5U
|
||||
# gGRUOUiAhrHXNZ6wG6T52NGQppehghc/MIIXOwYKKwYBBAGCNwMDATGCFyswghcn
|
||||
# BgkqhkiG9w0BBwKgghcYMIIXFAIBAzEPMA0GCWCGSAFlAwQCAQUAMHcGCyqGSIb3
|
||||
# DQEJEAEEoGgEZjBkAgEBBglghkgBhv1sBwEwMTANBglghkgBZQMEAgEFAAQg+eJt
|
||||
# Pwl5Hz89rrpf2qbsjNAUNlBq9SGjVuw+Erci2HcCEDPjoeI//+uRP30fqUoeHIAY
|
||||
# DzIwMjQwNjA2MTk1MDE0WqCCEwkwggbCMIIEqqADAgECAhAFRK/zlJ0IOaa/2z9f
|
||||
# 5WEWMA0GCSqGSIb3DQEBCwUAMGMxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdp
|
||||
# Q2VydCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1c3RlZCBHNCBSU0E0MDk2
|
||||
# IFNIQTI1NiBUaW1lU3RhbXBpbmcgQ0EwHhcNMjMwNzE0MDAwMDAwWhcNMzQxMDEz
|
||||
# MjM1OTU5WjBIMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4x
|
||||
# IDAeBgNVBAMTF0RpZ2lDZXJ0IFRpbWVzdGFtcCAyMDIzMIICIjANBgkqhkiG9w0B
|
||||
# AQEFAAOCAg8AMIICCgKCAgEAo1NFhx2DjlusPlSzI+DPn9fl0uddoQ4J3C9Io5d6
|
||||
# OyqcZ9xiFVjBqZMRp82qsmrdECmKHmJjadNYnDVxvzqX65RQjxwg6seaOy+WZuNp
|
||||
# 52n+W8PWKyAcwZeUtKVQgfLPywemMGjKg0La/H8JJJSkghraarrYO8pd3hkYhftF
|
||||
# 6g1hbJ3+cV7EBpo88MUueQ8bZlLjyNY+X9pD04T10Mf2SC1eRXWWdf7dEKEbg8G4
|
||||
# 5lKVtUfXeCk5a+B4WZfjRCtK1ZXO7wgX6oJkTf8j48qG7rSkIWRw69XloNpjsy7p
|
||||
# Be6q9iT1HbybHLK3X9/w7nZ9MZllR1WdSiQvrCuXvp/k/XtzPjLuUjT71Lvr1KAs
|
||||
# NJvj3m5kGQc3AZEPHLVRzapMZoOIaGK7vEEbeBlt5NkP4FhB+9ixLOFRr7StFQYU
|
||||
# 6mIIE9NpHnxkTZ0P387RXoyqq1AVybPKvNfEO2hEo6U7Qv1zfe7dCv95NBB+plwK
|
||||
# WEwAPoVpdceDZNZ1zY8SdlalJPrXxGshuugfNJgvOuprAbD3+yqG7HtSOKmYCaFx
|
||||
# smxxrz64b5bV4RAT/mFHCoz+8LbH1cfebCTwv0KCyqBxPZySkwS0aXAnDU+3tTbR
|
||||
# yV8IpHCj7ArxES5k4MsiK8rxKBMhSVF+BmbTO77665E42FEHypS34lCh8zrTioPL
|
||||
# QHsCAwEAAaOCAYswggGHMA4GA1UdDwEB/wQEAwIHgDAMBgNVHRMBAf8EAjAAMBYG
|
||||
# A1UdJQEB/wQMMAoGCCsGAQUFBwMIMCAGA1UdIAQZMBcwCAYGZ4EMAQQCMAsGCWCG
|
||||
# SAGG/WwHATAfBgNVHSMEGDAWgBS6FtltTYUvcyl2mi91jGogj57IbzAdBgNVHQ4E
|
||||
# FgQUpbbvE+fvzdBkodVWqWUxo97V40kwWgYDVR0fBFMwUTBPoE2gS4ZJaHR0cDov
|
||||
# L2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0UlNBNDA5NlNIQTI1
|
||||
# NlRpbWVTdGFtcGluZ0NBLmNybDCBkAYIKwYBBQUHAQEEgYMwgYAwJAYIKwYBBQUH
|
||||
# MAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBYBggrBgEFBQcwAoZMaHR0cDov
|
||||
# L2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0UlNBNDA5NlNI
|
||||
# QTI1NlRpbWVTdGFtcGluZ0NBLmNydDANBgkqhkiG9w0BAQsFAAOCAgEAgRrW3qCp
|
||||
# tZgXvHCNT4o8aJzYJf/LLOTN6l0ikuyMIgKpuM+AqNnn48XtJoKKcS8Y3U623mzX
|
||||
# 4WCcK+3tPUiOuGu6fF29wmE3aEl3o+uQqhLXJ4Xzjh6S2sJAOJ9dyKAuJXglnSoF
|
||||
# eoQpmLZXeY/bJlYrsPOnvTcM2Jh2T1a5UsK2nTipgedtQVyMadG5K8TGe8+c+nji
|
||||
# kxp2oml101DkRBK+IA2eqUTQ+OVJdwhaIcW0z5iVGlS6ubzBaRm6zxbygzc0brBB
|
||||
# Jt3eWpdPM43UjXd9dUWhpVgmagNF3tlQtVCMr1a9TMXhRsUo063nQwBw3syYnhmJ
|
||||
# A+rUkTfvTVLzyWAhxFZH7doRS4wyw4jmWOK22z75X7BC1o/jF5HRqsBV44a/rCcs
|
||||
# QdCaM0qoNtS5cpZ+l3k4SF/Kwtw9Mt911jZnWon49qfH5U81PAC9vpwqbHkB3NpE
|
||||
# 5jreODsHXjlY9HxzMVWggBHLFAx+rrz+pOt5Zapo1iLKO+uagjVXKBbLafIymrLS
|
||||
# 2Dq4sUaGa7oX/cR3bBVsrquvczroSUa31X/MtjjA2Owc9bahuEMs305MfR5ocMB3
|
||||
# CtQC4Fxguyj/OOVSWtasFyIjTvTs0xf7UGv/B3cfcZdEQcm4RtNsMnxYL2dHZeUb
|
||||
# c7aZ+WssBkbvQR7w8F/g29mtkIBEr4AQQYowggauMIIElqADAgECAhAHNje3JFR8
|
||||
# 2Ees/ShmKl5bMA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQK
|
||||
# EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNV
|
||||
# BAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDAeFw0yMjAzMjMwMDAwMDBaFw0z
|
||||
# NzAzMjIyMzU5NTlaMGMxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwg
|
||||
# SW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1c3RlZCBHNCBSU0E0MDk2IFNIQTI1
|
||||
# NiBUaW1lU3RhbXBpbmcgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoIC
|
||||
# AQDGhjUGSbPBPXJJUVXHJQPE8pE3qZdRodbSg9GeTKJtoLDMg/la9hGhRBVCX6SI
|
||||
# 82j6ffOciQt/nR+eDzMfUBMLJnOWbfhXqAJ9/UO0hNoR8XOxs+4rgISKIhjf69o9
|
||||
# xBd/qxkrPkLcZ47qUT3w1lbU5ygt69OxtXXnHwZljZQp09nsad/ZkIdGAHvbREGJ
|
||||
# 3HxqV3rwN3mfXazL6IRktFLydkf3YYMZ3V+0VAshaG43IbtArF+y3kp9zvU5Emfv
|
||||
# DqVjbOSmxR3NNg1c1eYbqMFkdECnwHLFuk4fsbVYTXn+149zk6wsOeKlSNbwsDET
|
||||
# qVcplicu9Yemj052FVUmcJgmf6AaRyBD40NjgHt1biclkJg6OBGz9vae5jtb7IHe
|
||||
# IhTZgirHkr+g3uM+onP65x9abJTyUpURK1h0QCirc0PO30qhHGs4xSnzyqqWc0Jo
|
||||
# n7ZGs506o9UD4L/wojzKQtwYSH8UNM/STKvvmz3+DrhkKvp1KCRB7UK/BZxmSVJQ
|
||||
# 9FHzNklNiyDSLFc1eSuo80VgvCONWPfcYd6T/jnA+bIwpUzX6ZhKWD7TA4j+s4/T
|
||||
# Xkt2ElGTyYwMO1uKIqjBJgj5FBASA31fI7tk42PgpuE+9sJ0sj8eCXbsq11GdeJg
|
||||
# o1gJASgADoRU7s7pXcheMBK9Rp6103a50g5rmQzSM7TNsQIDAQABo4IBXTCCAVkw
|
||||
# EgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUuhbZbU2FL3MpdpovdYxqII+e
|
||||
# yG8wHwYDVR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQD
|
||||
# AgGGMBMGA1UdJQQMMAoGCCsGAQUFBwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEF
|
||||
# BQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRw
|
||||
# Oi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNy
|
||||
# dDBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGln
|
||||
# aUNlcnRUcnVzdGVkUm9vdEc0LmNybDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglg
|
||||
# hkgBhv1sBwEwDQYJKoZIhvcNAQELBQADggIBAH1ZjsCTtm+YqUQiAX5m1tghQuGw
|
||||
# GC4QTRPPMFPOvxj7x1Bd4ksp+3CKDaopafxpwc8dB+k+YMjYC+VcW9dth/qEICU0
|
||||
# MWfNthKWb8RQTGIdDAiCqBa9qVbPFXONASIlzpVpP0d3+3J0FNf/q0+KLHqrhc1D
|
||||
# X+1gtqpPkWaeLJ7giqzl/Yy8ZCaHbJK9nXzQcAp876i8dU+6WvepELJd6f8oVInw
|
||||
# 1YpxdmXazPByoyP6wCeCRK6ZJxurJB4mwbfeKuv2nrF5mYGjVoarCkXJ38SNoOeY
|
||||
# +/umnXKvxMfBwWpx2cYTgAnEtp/Nh4cku0+jSbl3ZpHxcpzpSwJSpzd+k1OsOx0I
|
||||
# SQ+UzTl63f8lY5knLD0/a6fxZsNBzU+2QJshIUDQtxMkzdwdeDrknq3lNHGS1yZr
|
||||
# 5Dhzq6YBT70/O3itTK37xJV77QpfMzmHQXh6OOmc4d0j/R0o08f56PGYX/sr2H7y
|
||||
# Rp11LB4nLCbbbxV7HhmLNriT1ObyF5lZynDwN7+YAN8gFk8n+2BnFqFmut1VwDop
|
||||
# hrCYoCvtlUG3OtUVmDG0YgkPCr2B2RP+v6TR81fZvAT6gt4y3wSJ8ADNXcL50CN/
|
||||
# AAvkdgIm2fBldkKmKYcJRyvmfxqkhQ/8mJb2VVQrH4D6wPIOK+XW+6kvRBVK5xMO
|
||||
# Hds3OBqhK/bt1nz8MIIFjTCCBHWgAwIBAgIQDpsYjvnQLefv21DiCEAYWjANBgkq
|
||||
# hkiG9w0BAQwFADBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5j
|
||||
# MRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBB
|
||||
# c3N1cmVkIElEIFJvb3QgQ0EwHhcNMjIwODAxMDAwMDAwWhcNMzExMTA5MjM1OTU5
|
||||
# WjBiMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQL
|
||||
# ExB3d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJv
|
||||
# b3QgRzQwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN675F1K
|
||||
# PDAiMGkz7MKnJS7JIT3yithZwuEppz1Yq3aaza57G4QNxDAf8xukOBbrVsaXbR2r
|
||||
# snnyyhHS5F/WBTxSD1Ifxp4VpX6+n6lXFllVcq9ok3DCsrp1mWpzMpTREEQQLt+C
|
||||
# 8weE5nQ7bXHiLQwb7iDVySAdYyktzuxeTsiT+CFhmzTrBcZe7FsavOvJz82sNEBf
|
||||
# sXpm7nfISKhmV1efVFiODCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4DatpGY
|
||||
# QJB5w3jHtrHEtWoYOAMQjdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8
|
||||
# rhsDdV14Ztk6MUSaM0C/CNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98FpiHaY
|
||||
# dj1ZXUJ2h4mXaXpI8OCiEhtmmnTK3kse5w5jrubU75KSOp493ADkRSWJtppEGSt+
|
||||
# wJS00mFt6zPZxd9LBADMfRyVw4/3IbKyEbe7f/LVjHAsQWCqsWMYRJUadmJ+9oCw
|
||||
# ++hkpjPRiQfhvbfmQ6QYuKZ3AeEPlAwhHbJUKSWJbOUOUlFHdL4mrLZBdd56rF+N
|
||||
# P8m800ERElvlEFDrMcXKchYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8oR7F
|
||||
# wI+isX4KJpn15GkvmB0t9dmpsh3lGwIDAQABo4IBOjCCATYwDwYDVR0TAQH/BAUw
|
||||
# AwEB/zAdBgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wHwYDVR0jBBgwFoAU
|
||||
# Reuir/SSy4IxLVGLp6chnfNtyA8wDgYDVR0PAQH/BAQDAgGGMHkGCCsGAQUFBwEB
|
||||
# BG0wazAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEMGCCsG
|
||||
# AQUFBzAChjdodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRBc3N1
|
||||
# cmVkSURSb290Q0EuY3J0MEUGA1UdHwQ+MDwwOqA4oDaGNGh0dHA6Ly9jcmwzLmRp
|
||||
# Z2ljZXJ0LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcmwwEQYDVR0gBAow
|
||||
# CDAGBgRVHSAAMA0GCSqGSIb3DQEBDAUAA4IBAQBwoL9DXFXnOF+go3QbPbYW1/e/
|
||||
# Vwe9mqyhhyzshV6pGrsi+IcaaVQi7aSId229GhT0E0p6Ly23OO/0/4C5+KH38nLe
|
||||
# JLxSA8hO0Cre+i1Wz/n096wwepqLsl7Uz9FDRJtDIeuWcqFItJnLnU+nBgMTdydE
|
||||
# 1Od/6Fmo8L8vC6bp8jQ87PcDx4eo0kxAGTVGamlUsLihVo7spNU96LHc/RzY9Hda
|
||||
# XFSMb++hUD38dglohJ9vytsgjTVgHAIDyyCwrFigDkBjxZgiwbJZ9VVrzyerbHbO
|
||||
# byMt9H5xaiNrIv8SuFQtJ37YOtnwtoeW/VvRXKwYw02fc7cBqZ9Xql4o4rmUMYID
|
||||
# djCCA3ICAQEwdzBjMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIElu
|
||||
# Yy4xOzA5BgNVBAMTMkRpZ2lDZXJ0IFRydXN0ZWQgRzQgUlNBNDA5NiBTSEEyNTYg
|
||||
# VGltZVN0YW1waW5nIENBAhAFRK/zlJ0IOaa/2z9f5WEWMA0GCWCGSAFlAwQCAQUA
|
||||
# oIHRMBoGCSqGSIb3DQEJAzENBgsqhkiG9w0BCRABBDAcBgkqhkiG9w0BCQUxDxcN
|
||||
# MjQwNjA2MTk1MDE0WjArBgsqhkiG9w0BCRACDDEcMBowGDAWBBRm8CsywsLJD4Jd
|
||||
# zqqKycZPGZzPQDAvBgkqhkiG9w0BCQQxIgQgUvswt0fWRoofHUAuTE0/8V9tLmHP
|
||||
# zr/l2RTobZjBdqYwNwYLKoZIhvcNAQkQAi8xKDAmMCQwIgQg0vbkbe10IszR1EBX
|
||||
# aEE2b4KK2lWarjMWr00amtQMeCgwDQYJKoZIhvcNAQEBBQAEggIAc7/uG/S8kf0i
|
||||
# 2kaDQkE8NSfiXCYfN7z/2sgi6RNrkipvs/KTWfEKuMbhu9qWjjusZFgywn/IrZqw
|
||||
# td4Js1kmaN+HJ02t/HXYUCr+KTJye4mDaBGvaXXHllCqsK7bhsJxJYE0uYiL03MP
|
||||
# g64jyu9WdJD3N26MW/DkO6HTVhYzRzjafbAKbrr8KCvaFan1KZERzYwbA8XVjm88
|
||||
# HOodLCA9h+91Iqdc+uSz3Sg9/+Ns4zCp4BonvnsPYTlWTitiB5cpfPe/v4lBvCNu
|
||||
# x0ha6whvKMdRLZJgXsiDXo2NwwB55kkWEBwD3a1RnBJQmyJxFEGpSXOrhmdcEWPg
|
||||
# fjoHVIfowKBrIgINdWJbvIu+pLzQRMkVhuJzB32xpiZBIvbzkPETYQMOmKIu40I9
|
||||
# 5EAL0xNakPxYiT3nTkncn6woLOhiOXFm7crE+gO4IzDNauYuT9Vfe36K1CqtuYSy
|
||||
# JesLIey9Z81OQqOo6n2/lW110MKMEV2PkPU7YW/bYO2uKsZ3OAjUWr63nMT+M2wk
|
||||
# VdUAcqm0QdZsELY75Q3ekRxHje/B9ePP4Q4RMQGOZvmgqdtEeFhsmRwufR4fzfqx
|
||||
# WMttmOHelTd8Sc0sfA9B+1dxtiC9GFn3de5/o+T2s/jQn6eNp2hvlCqGV0iFzSQp
|
||||
# InPTBa9Na/+5UeXZ3NBWRvarfZ62TVM=
|
||||
# SIG # End signature block
|
||||
70
Scripts/activate
Normal file
70
Scripts/activate
Normal file
@@ -0,0 +1,70 @@
|
||||
# This file must be used with "source bin/activate" *from bash*
|
||||
# You cannot run it directly
|
||||
|
||||
deactivate () {
|
||||
# reset old environment variables
|
||||
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
|
||||
PATH="${_OLD_VIRTUAL_PATH:-}"
|
||||
export PATH
|
||||
unset _OLD_VIRTUAL_PATH
|
||||
fi
|
||||
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
|
||||
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
|
||||
export PYTHONHOME
|
||||
unset _OLD_VIRTUAL_PYTHONHOME
|
||||
fi
|
||||
|
||||
# Call hash to forget past commands. Without forgetting
|
||||
# past commands the $PATH changes we made may not be respected
|
||||
hash -r 2> /dev/null
|
||||
|
||||
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
|
||||
PS1="${_OLD_VIRTUAL_PS1:-}"
|
||||
export PS1
|
||||
unset _OLD_VIRTUAL_PS1
|
||||
fi
|
||||
|
||||
unset VIRTUAL_ENV
|
||||
unset VIRTUAL_ENV_PROMPT
|
||||
if [ ! "${1:-}" = "nondestructive" ] ; then
|
||||
# Self destruct!
|
||||
unset -f deactivate
|
||||
fi
|
||||
}
|
||||
|
||||
# unset irrelevant variables
|
||||
deactivate nondestructive
|
||||
|
||||
# on Windows, a path can contain colons and backslashes and has to be converted:
|
||||
if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then
|
||||
# transform D:\path\to\venv to /d/path/to/venv on MSYS
|
||||
# and to /cygdrive/d/path/to/venv on Cygwin
|
||||
export VIRTUAL_ENV=$(cygpath "c:\Users\Lockeshor\Documents\Code\csc-checkin\csc-checkin.venv")
|
||||
else
|
||||
# use the path as-is
|
||||
export VIRTUAL_ENV="c:\Users\Lockeshor\Documents\Code\csc-checkin\csc-checkin.venv"
|
||||
fi
|
||||
|
||||
_OLD_VIRTUAL_PATH="$PATH"
|
||||
PATH="$VIRTUAL_ENV/Scripts:$PATH"
|
||||
export PATH
|
||||
|
||||
# unset PYTHONHOME if set
|
||||
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
|
||||
# could use `if (set -u; : $PYTHONHOME) ;` in bash
|
||||
if [ -n "${PYTHONHOME:-}" ] ; then
|
||||
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
|
||||
unset PYTHONHOME
|
||||
fi
|
||||
|
||||
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
|
||||
_OLD_VIRTUAL_PS1="${PS1:-}"
|
||||
PS1="(csc-checkin.venv) ${PS1:-}"
|
||||
export PS1
|
||||
VIRTUAL_ENV_PROMPT="(csc-checkin.venv) "
|
||||
export VIRTUAL_ENV_PROMPT
|
||||
fi
|
||||
|
||||
# Call hash to forget past commands. Without forgetting
|
||||
# past commands the $PATH changes we made may not be respected
|
||||
hash -r 2> /dev/null
|
||||
34
Scripts/activate.bat
Normal file
34
Scripts/activate.bat
Normal file
@@ -0,0 +1,34 @@
|
||||
@echo off
|
||||
|
||||
rem This file is UTF-8 encoded, so we need to update the current code page while executing it
|
||||
for /f "tokens=2 delims=:." %%a in ('"%SystemRoot%\System32\chcp.com"') do (
|
||||
set _OLD_CODEPAGE=%%a
|
||||
)
|
||||
if defined _OLD_CODEPAGE (
|
||||
"%SystemRoot%\System32\chcp.com" 65001 > nul
|
||||
)
|
||||
|
||||
set VIRTUAL_ENV=c:\Users\Lockeshor\Documents\Code\csc-checkin\csc-checkin.venv
|
||||
|
||||
if not defined PROMPT set PROMPT=$P$G
|
||||
|
||||
if defined _OLD_VIRTUAL_PROMPT set PROMPT=%_OLD_VIRTUAL_PROMPT%
|
||||
if defined _OLD_VIRTUAL_PYTHONHOME set PYTHONHOME=%_OLD_VIRTUAL_PYTHONHOME%
|
||||
|
||||
set _OLD_VIRTUAL_PROMPT=%PROMPT%
|
||||
set PROMPT=(csc-checkin.venv) %PROMPT%
|
||||
|
||||
if defined PYTHONHOME set _OLD_VIRTUAL_PYTHONHOME=%PYTHONHOME%
|
||||
set PYTHONHOME=
|
||||
|
||||
if defined _OLD_VIRTUAL_PATH set PATH=%_OLD_VIRTUAL_PATH%
|
||||
if not defined _OLD_VIRTUAL_PATH set _OLD_VIRTUAL_PATH=%PATH%
|
||||
|
||||
set PATH=%VIRTUAL_ENV%\Scripts;%PATH%
|
||||
set VIRTUAL_ENV_PROMPT=(csc-checkin.venv)
|
||||
|
||||
:END
|
||||
if defined _OLD_CODEPAGE (
|
||||
"%SystemRoot%\System32\chcp.com" %_OLD_CODEPAGE% > nul
|
||||
set _OLD_CODEPAGE=
|
||||
)
|
||||
22
Scripts/deactivate.bat
Normal file
22
Scripts/deactivate.bat
Normal file
@@ -0,0 +1,22 @@
|
||||
@echo off
|
||||
|
||||
if defined _OLD_VIRTUAL_PROMPT (
|
||||
set "PROMPT=%_OLD_VIRTUAL_PROMPT%"
|
||||
)
|
||||
set _OLD_VIRTUAL_PROMPT=
|
||||
|
||||
if defined _OLD_VIRTUAL_PYTHONHOME (
|
||||
set "PYTHONHOME=%_OLD_VIRTUAL_PYTHONHOME%"
|
||||
set _OLD_VIRTUAL_PYTHONHOME=
|
||||
)
|
||||
|
||||
if defined _OLD_VIRTUAL_PATH (
|
||||
set "PATH=%_OLD_VIRTUAL_PATH%"
|
||||
)
|
||||
|
||||
set _OLD_VIRTUAL_PATH=
|
||||
|
||||
set VIRTUAL_ENV=
|
||||
set VIRTUAL_ENV_PROMPT=
|
||||
|
||||
:END
|
||||
BIN
Scripts/flask.exe
Normal file
BIN
Scripts/flask.exe
Normal file
Binary file not shown.
BIN
Scripts/pip.exe
Normal file
BIN
Scripts/pip.exe
Normal file
Binary file not shown.
BIN
Scripts/pip3.12.exe
Normal file
BIN
Scripts/pip3.12.exe
Normal file
Binary file not shown.
BIN
Scripts/pip3.exe
Normal file
BIN
Scripts/pip3.exe
Normal file
Binary file not shown.
BIN
Scripts/python.exe
Normal file
BIN
Scripts/python.exe
Normal file
Binary file not shown.
BIN
Scripts/pythonw.exe
Normal file
BIN
Scripts/pythonw.exe
Normal file
Binary file not shown.
BIN
backups/pool_backup_20260306_000244.sqlite3
Normal file
BIN
backups/pool_backup_20260306_000244.sqlite3
Normal file
Binary file not shown.
BIN
backups/pool_backup_20260306_001656.sqlite3
Normal file
BIN
backups/pool_backup_20260306_001656.sqlite3
Normal file
Binary file not shown.
11
exports/pool_members_20260305_233159.csv
Normal file
11
exports/pool_members_20260305_233159.csv
Normal file
@@ -0,0 +1,11 @@
|
||||
member_id,first_name,last_name,age,household,checked_in,swim_test_passed,can_use_deep_end
|
||||
4,Luis,Garcia,41,Garcia,1,1,1
|
||||
6,Mateo,Garcia,11,Garcia,1,1,1
|
||||
5,Sofia,Garcia,38,Garcia,0,1,1
|
||||
3,Emma,James,29,Individual,1,1,1
|
||||
2,Noah,Patel,42,Individual,0,1,1
|
||||
1,Avery,Wilson,34,Individual,1,1,1
|
||||
7,Helen,Miller,37,Miller,1,1,1
|
||||
8,Kai,Miller,9,Miller,1,0,0
|
||||
10,Liam,Nguyen,8,Nguyen,0,0,0
|
||||
9,Trang,Nguyen,35,Nguyen,0,1,1
|
||||
|
11
exports/pool_members_20260306_000251.csv
Normal file
11
exports/pool_members_20260306_000251.csv
Normal file
@@ -0,0 +1,11 @@
|
||||
member_id,first_name,last_name,age,household,checked_in,swim_test_passed,can_use_deep_end
|
||||
4,Luis,Garcia,41,Garcia,0,0,1
|
||||
6,Mateo,Garcia,11,Garcia,0,0,0
|
||||
5,Sofia,Garcia,38,Garcia,0,0,1
|
||||
3,Emma,James,29,Individual,0,0,1
|
||||
2,Noah,Patel,42,Individual,0,0,1
|
||||
1,Avery,Wilson,34,Individual,0,0,1
|
||||
7,Helen,Miller,37,Miller,0,0,1
|
||||
8,Kai,Miller,9,Miller,0,0,0
|
||||
10,Liam,Nguyen,8,Nguyen,0,0,0
|
||||
9,Trang,Nguyen,35,Nguyen,0,0,1
|
||||
|
13
exports/pool_members_20260306_000949.csv
Normal file
13
exports/pool_members_20260306_000949.csv
Normal file
@@ -0,0 +1,13 @@
|
||||
member_id,first_name,last_name,age,family_id,family_name,primary_phone,checked_in,swim_test_passed,requires_swim_test
|
||||
4,Luis,Garcia,41,1,Garcia,555-555-0141,0,0,0
|
||||
6,Mateo,Garcia,11,1,Garcia,555-555-0141,0,0,1
|
||||
5,Sofia,Garcia,38,1,Garcia,555-555-0141,0,0,0
|
||||
11,John,Gracia-laquez,16,1,Garcia,555-555-0141,0,0,0
|
||||
3,Emma,James,29,,Individual,,0,0,0
|
||||
2,Noah,Patel,42,,Individual,,0,0,0
|
||||
1,Avery,Wilson,34,,Individual,,0,0,0
|
||||
7,Helen,Miller,37,2,Miller,555-555-0187,0,0,0
|
||||
8,Kai,Miller,9,2,Miller,555-555-0187,0,0,1
|
||||
12,Langlostean,Nguyen,11,3,Nguyen,555-555-0179,0,0,1
|
||||
10,Liam,Nguyen,8,3,Nguyen,555-555-0179,0,0,1
|
||||
9,Trang,Nguyen,35,3,Nguyen,555-555-0179,0,0,0
|
||||
|
13
exports/pool_members_20260306_001657.csv
Normal file
13
exports/pool_members_20260306_001657.csv
Normal file
@@ -0,0 +1,13 @@
|
||||
member_id,first_name,last_name,age,family_id,family_name,primary_phone,checked_in,swim_test_passed,requires_swim_test
|
||||
4,Luis,Garcia,41,1,Garcia,555-555-0141,0,0,0
|
||||
6,Mateo,Garcia,11,1,Garcia,555-555-0141,0,0,1
|
||||
5,Sofia,Garcia,38,1,Garcia,555-555-0141,0,0,0
|
||||
11,John,Gracia-laquez,16,1,Garcia,555-555-0141,0,0,0
|
||||
3,Emma,James,29,,Individual,,0,0,0
|
||||
2,Noah,Patel,42,,Individual,,0,0,0
|
||||
1,Avery,Wilson,34,,Individual,,0,0,0
|
||||
7,Helen,Miller,37,2,Miller,555-555-0187,0,0,0
|
||||
8,Kai,Miller,9,2,Miller,555-555-0187,0,0,1
|
||||
12,Langlostean,Nguyen,11,3,Nguyen,555-555-0179,0,0,1
|
||||
10,Liam,Nguyen,8,3,Nguyen,555-555-0179,0,0,1
|
||||
9,Trang,Nguyen,35,3,Nguyen,555-555-0179,0,0,0
|
||||
|
13
exports/pool_members_20260306_003607.csv
Normal file
13
exports/pool_members_20260306_003607.csv
Normal file
@@ -0,0 +1,13 @@
|
||||
member_id,first_name,last_name,age,family_id,family_name,primary_phone,checked_in,swim_test_passed,requires_swim_test
|
||||
4,Luis,Garcia,41,1,Garcia,555-555-0141,0,0,0
|
||||
6,Mateo,Garcia,11,1,Garcia,555-555-0141,0,0,1
|
||||
5,Sofia,Garcia,38,1,Garcia,555-555-0141,0,0,0
|
||||
11,John,Gracia-laquez,16,1,Garcia,555-555-0141,0,0,0
|
||||
3,Emma,James,29,,Individual,,0,0,0
|
||||
2,Noah,Patel,42,,Individual,,0,0,0
|
||||
1,Avery,Wilson,34,,Individual,,0,0,0
|
||||
7,Helen,Miller,37,2,Miller,555-555-0187,0,0,0
|
||||
8,Kai,Miller,9,2,Miller,555-555-0187,0,0,1
|
||||
12,Langlostean,Nguyen,11,3,Nguyen,555-555-0179,0,0,1
|
||||
10,Liam,Nguyen,8,3,Nguyen,555-555-0179,0,0,1
|
||||
9,Trang,Nguyen,35,3,Nguyen,555-555-0179,0,0,0
|
||||
|
13
exports/pool_members_20260306_004335.csv
Normal file
13
exports/pool_members_20260306_004335.csv
Normal file
@@ -0,0 +1,13 @@
|
||||
member_id,first_name,last_name,age,family_id,family_name,primary_phone,checked_in,swim_test_passed,requires_swim_test
|
||||
4,Luis,Garcia,41,1,Garcia,555-555-0141,0,0,0
|
||||
6,Mateo,Garcia,11,1,Garcia,555-555-0141,0,0,1
|
||||
5,Sofia,Garcia,38,1,Garcia,555-555-0141,0,0,0
|
||||
11,John,Gracia-laquez,16,1,Garcia,555-555-0141,0,0,0
|
||||
3,Emma,James,29,,Individual,,0,0,0
|
||||
2,Noah,Patel,42,,Individual,,0,0,0
|
||||
1,Avery,Wilson,34,,Individual,,0,0,0
|
||||
7,Helen,Miller,37,2,Miller,555-555-0187,0,0,0
|
||||
8,Kai,Miller,9,2,Miller,555-555-0187,0,0,1
|
||||
12,Langlostean,Nguyen,11,3,Nguyen,555-555-0179,0,0,1
|
||||
10,Liam,Nguyen,8,3,Nguyen,555-555-0179,0,0,1
|
||||
9,Trang,Nguyen,35,3,Nguyen,555-555-0179,0,0,0
|
||||
|
786
family.html
Normal file
786
family.html
Normal file
@@ -0,0 +1,786 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cary Swim Club Family Check-In</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--ink: #10231a;
|
||||
--ink-soft: #4b5f56;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f4fbef;
|
||||
--line: #d8e8cf;
|
||||
--good: #3e9f14;
|
||||
--warn: #ba5d17;
|
||||
--deep: #2b6ed4;
|
||||
--deep-bg: #dbe9ff;
|
||||
--safe: #9f3232;
|
||||
--accent: #4cbb17;
|
||||
--accent-strong: #3b9612;
|
||||
--bg-a: #f6fbf2;
|
||||
--bg-b: #deefd1;
|
||||
--hero-a: #1f3e22;
|
||||
--hero-b: #2f642d;
|
||||
--focus-ring: rgba(76, 187, 23, 0.28);
|
||||
--shadow: 0 16px 36px rgba(19, 45, 23, 0.14);
|
||||
}
|
||||
|
||||
body[data-theme="dark"] {
|
||||
--ink: #e7f6df;
|
||||
--ink-soft: #a5c1af;
|
||||
--surface: #132118;
|
||||
--surface-soft: #1a2d21;
|
||||
--line: #264032;
|
||||
--good: #65d82c;
|
||||
--warn: #f2a45b;
|
||||
--deep: #7db7ff;
|
||||
--deep-bg: #1b3157;
|
||||
--safe: #f08181;
|
||||
--accent: #65d82c;
|
||||
--accent-strong: #4cbb17;
|
||||
--bg-a: #0a140d;
|
||||
--bg-b: #12211a;
|
||||
--hero-a: #15341c;
|
||||
--hero-b: #1f4c28;
|
||||
--focus-ring: rgba(101, 216, 44, 0.25);
|
||||
--shadow: 0 18px 40px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at 20% 0%, rgba(76, 187, 23, 0.22), transparent 30%),
|
||||
radial-gradient(circle at 95% 95%, rgba(61, 120, 67, 0.2), transparent 28%),
|
||||
linear-gradient(165deg, var(--bg-a), var(--bg-b));
|
||||
background-attachment: fixed;
|
||||
padding: 24px;
|
||||
transition: background 260ms ease, color 260ms ease;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1050px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.top {
|
||||
background:
|
||||
linear-gradient(120deg, rgba(255, 255, 255, 0.05), transparent 42%),
|
||||
linear-gradient(140deg, var(--hero-a), var(--hero-b));
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
color: #efffe8;
|
||||
box-shadow: var(--shadow);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.top::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 210px;
|
||||
height: 210px;
|
||||
right: -60px;
|
||||
top: -102px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(151, 255, 109, 0.25), transparent 70%);
|
||||
}
|
||||
|
||||
.top h1 {
|
||||
margin: 0;
|
||||
font-family: "Archivo Black", "Impact", sans-serif;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: clamp(1.4rem, 2vw + 1rem, 2.2rem);
|
||||
}
|
||||
|
||||
.top p {
|
||||
margin: 8px 0 0;
|
||||
color: #d8f6c8;
|
||||
}
|
||||
|
||||
.top-controls {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.guest-pass-panel {
|
||||
background: rgba(10, 31, 10, 0.22);
|
||||
border: 1px solid rgba(180, 244, 148, 0.35);
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.guest-pass-label {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.guest-pass-label strong {
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #d8f6c8;
|
||||
}
|
||||
|
||||
.guest-pass-value {
|
||||
font-family: "Archivo Black", "Impact", sans-serif;
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
color: #f7ffe9;
|
||||
}
|
||||
|
||||
.guest-pass-actions {
|
||||
margin-left: auto;
|
||||
display: inline-grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pass-btn {
|
||||
border: 1px solid rgba(196, 250, 166, 0.35);
|
||||
background: rgba(6, 28, 12, 0.34);
|
||||
color: #f0ffe5;
|
||||
border-radius: 9px;
|
||||
min-width: 50px;
|
||||
padding: 8px 10px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pass-btn:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-main {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: var(--surface-soft);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(2px);
|
||||
transition: background 220ms ease, border-color 220ms ease;
|
||||
}
|
||||
|
||||
.rule-banner {
|
||||
margin-bottom: 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed var(--line);
|
||||
background: var(--surface-soft);
|
||||
color: var(--ink-soft);
|
||||
padding: 10px 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.section-block {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.section-meta {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.member-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.member {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, var(--surface), var(--surface-soft));
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease;
|
||||
animation: card-in 260ms ease both;
|
||||
align-self: start;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.member::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -20px;
|
||||
bottom: -25px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(76, 187, 23, 0.18), rgba(76, 187, 23, 0.02));
|
||||
}
|
||||
|
||||
.member:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, var(--line));
|
||||
box-shadow: 0 12px 24px rgba(19, 45, 79, 0.12);
|
||||
}
|
||||
|
||||
.member-head {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.member-title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.member-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, var(--accent), #2e8e34);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.member-icon svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.member-first,
|
||||
.member-last {
|
||||
display: block;
|
||||
font-size: 1.16rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.08;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.member-sub {
|
||||
margin: 4px 0 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.88rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-block;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chip-in {
|
||||
background: color-mix(in srgb, var(--accent) 20%, var(--surface));
|
||||
color: var(--good);
|
||||
}
|
||||
|
||||
.chip-out {
|
||||
background: #fbe8d9;
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.chip-deep {
|
||||
background: var(--deep-bg);
|
||||
color: var(--deep);
|
||||
}
|
||||
|
||||
.chip-shallow {
|
||||
background: #fde7e7;
|
||||
color: var(--safe);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
background: color-mix(in srgb, var(--surface-soft) 85%, transparent);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 3px;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.toggle button {
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
padding: 8px 8px;
|
||||
font: inherit;
|
||||
font-size: 0.83rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
color: var(--ink);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.toggle button.active {
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 20px rgba(9, 35, 67, 0.2);
|
||||
}
|
||||
|
||||
.state-in.active {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||
}
|
||||
|
||||
.state-out.active {
|
||||
background: linear-gradient(135deg, #ce7a32, #b75f17);
|
||||
}
|
||||
|
||||
.state-pass.active {
|
||||
background: linear-gradient(135deg, #3f87eb, #2b6ed4);
|
||||
}
|
||||
|
||||
.state-fail.active {
|
||||
background: linear-gradient(135deg, #ad3b3b, #912a2a);
|
||||
}
|
||||
|
||||
.empty-note {
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.86rem;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 9px 10px;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.chip-wrap {
|
||||
display: inline-flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.status {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 8px 10px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--ink-soft);
|
||||
background: var(--surface-soft);
|
||||
opacity: 0.86;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
body { padding: 14px; }
|
||||
.member-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.member-list { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<section class="top">
|
||||
<h1 id="familyTitle">Family</h1>
|
||||
<p id="familyMeta">Loading family...</p>
|
||||
<div class="top-controls">
|
||||
<div class="guest-pass-panel" aria-live="polite">
|
||||
<div class="guest-pass-label">
|
||||
<strong>Guest Passes</strong>
|
||||
<span id="guestPassCount" class="guest-pass-value">0</span>
|
||||
</div>
|
||||
<div class="guest-pass-actions">
|
||||
<button id="decreasePassBtn" class="pass-btn" type="button" aria-label="Decrease guest passes">-1</button>
|
||||
<button id="increasePassBtn" class="pass-btn" type="button" aria-label="Increase guest passes">+1</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="toolbar">
|
||||
<a class="btn btn-ghost" href="/">Back To Family Search</a>
|
||||
<button id="refreshBtn" class="btn btn-main" type="button">Refresh Family</button>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div id="ruleBanner" class="rule-banner">Swim test required under age 13.</div>
|
||||
|
||||
<section class="section-block">
|
||||
<div class="section-head">
|
||||
<h2 class="section-title">Adults</h2>
|
||||
<p class="section-meta" id="adultsMeta">0 members</p>
|
||||
</div>
|
||||
<div id="adultsList" class="member-list"></div>
|
||||
<div id="adultsEmpty" class="empty-note">No adults in this family.</div>
|
||||
</section>
|
||||
|
||||
<section class="section-block">
|
||||
<div class="section-head">
|
||||
<h2 class="section-title">Children</h2>
|
||||
<p class="section-meta" id="childrenMeta">0 members</p>
|
||||
</div>
|
||||
<div id="childrenList" class="member-list"></div>
|
||||
<div id="childrenEmpty" class="empty-note">No children in this family.</div>
|
||||
</section>
|
||||
|
||||
<div id="status" class="status">Ready.</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
function getPreferredTheme() {
|
||||
const saved = window.localStorage.getItem("csc-theme");
|
||||
if (saved === "dark" || saved === "light") {
|
||||
return saved;
|
||||
}
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
document.body.setAttribute("data-theme", theme);
|
||||
}
|
||||
|
||||
applyTheme(getPreferredTheme());
|
||||
|
||||
const familyTitle = document.getElementById("familyTitle");
|
||||
const familyMeta = document.getElementById("familyMeta");
|
||||
const ruleBanner = document.getElementById("ruleBanner");
|
||||
const adultsList = document.getElementById("adultsList");
|
||||
const childrenList = document.getElementById("childrenList");
|
||||
const adultsMeta = document.getElementById("adultsMeta");
|
||||
const childrenMeta = document.getElementById("childrenMeta");
|
||||
const adultsEmpty = document.getElementById("adultsEmpty");
|
||||
const childrenEmpty = document.getElementById("childrenEmpty");
|
||||
const statusBox = document.getElementById("status");
|
||||
const refreshBtn = document.getElementById("refreshBtn");
|
||||
const guestPassCount = document.getElementById("guestPassCount");
|
||||
const decreasePassBtn = document.getElementById("decreasePassBtn");
|
||||
const increasePassBtn = document.getElementById("increasePassBtn");
|
||||
let currentMinSwimTestAge = 13;
|
||||
let currentGuestPasses = 0;
|
||||
let isUpdatingGuestPasses = false;
|
||||
|
||||
function currentFamilyId() {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
const maybeId = parts[parts.length - 1];
|
||||
return Number.parseInt(maybeId, 10);
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...options,
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || payload.message || "Request failed");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
statusBox.textContent = message;
|
||||
}
|
||||
|
||||
function buildToggle(options, activeValue, onChoose) {
|
||||
const container = document.createElement("div");
|
||||
container.className = "toggle";
|
||||
|
||||
options.forEach((opt) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.textContent = opt.label;
|
||||
btn.className = `${opt.className} ${opt.value === activeValue ? "active" : ""}`;
|
||||
btn.addEventListener("click", () => onChoose(opt.value));
|
||||
container.append(btn);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
async function setCheckIn(memberId, checkedIn) {
|
||||
await api(`/api/members/${memberId}/checkin`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ checkedIn }),
|
||||
});
|
||||
}
|
||||
|
||||
async function setSwimTest(memberId, swimTestPassed) {
|
||||
await api(`/api/members/${memberId}/swim-test`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ swimTestPassed }),
|
||||
});
|
||||
}
|
||||
|
||||
function renderGuestPasses() {
|
||||
guestPassCount.textContent = String(currentGuestPasses);
|
||||
decreasePassBtn.disabled = isUpdatingGuestPasses || currentGuestPasses <= 0;
|
||||
increasePassBtn.disabled = isUpdatingGuestPasses;
|
||||
}
|
||||
|
||||
async function updateGuestPasses(nextCount) {
|
||||
if (!Number.isInteger(nextCount) || nextCount < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const familyId = currentFamilyId();
|
||||
if (!familyId || Number.isNaN(familyId)) {
|
||||
setStatus("Invalid family id.");
|
||||
return;
|
||||
}
|
||||
|
||||
isUpdatingGuestPasses = true;
|
||||
renderGuestPasses();
|
||||
try {
|
||||
setStatus("Updating guest passes...");
|
||||
const payload = await api(`/api/families/${familyId}/guest-passes`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ guestPasses: nextCount }),
|
||||
});
|
||||
currentGuestPasses = payload.item.guestPasses;
|
||||
setStatus(`Guest passes set to ${currentGuestPasses}.`);
|
||||
} catch (error) {
|
||||
setStatus(`Error: ${error.message}`);
|
||||
} finally {
|
||||
isUpdatingGuestPasses = false;
|
||||
renderGuestPasses();
|
||||
}
|
||||
}
|
||||
|
||||
function renderMember(member, refreshFamily) {
|
||||
const card = document.createElement("article");
|
||||
card.className = "member";
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "member-head";
|
||||
|
||||
const idWrap = document.createElement("div");
|
||||
idWrap.className = "member-title-wrap";
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "member-icon";
|
||||
icon.innerHTML = "<svg viewBox='0 0 24 24' aria-hidden='true'><path d='M12 12a5 5 0 1 0-5-5 5 5 0 0 0 5 5zm0 2c-4.42 0-8 2.24-8 5v1h16v-1c0-2.76-3.58-5-8-5z'/></svg>";
|
||||
|
||||
const textWrap = document.createElement("div");
|
||||
const title = document.createElement("h3");
|
||||
title.className = "member-name";
|
||||
title.innerHTML = `<span class="member-first">${member.firstName}</span><span class="member-last">${member.lastName}</span>`;
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.className = "member-sub";
|
||||
subtitle.textContent = `Age ${member.age}`;
|
||||
textWrap.append(title, subtitle);
|
||||
idWrap.append(icon, textWrap);
|
||||
|
||||
head.append(idWrap);
|
||||
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "row";
|
||||
|
||||
controls.append(
|
||||
buildToggle(
|
||||
[
|
||||
{ label: "Checked Out", value: false, className: "state-out" },
|
||||
{ label: "Checked In", value: true, className: "state-in" },
|
||||
],
|
||||
!!member.checkedIn,
|
||||
async (value) => {
|
||||
try {
|
||||
setStatus(`Updating check-in for ${member.fullName}...`);
|
||||
await setCheckIn(member.id, value);
|
||||
await refreshFamily();
|
||||
setStatus(`Updated check-in for ${member.fullName}.`);
|
||||
} catch (error) {
|
||||
setStatus(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
if (member.requiresSwimTest) {
|
||||
controls.append(
|
||||
buildToggle(
|
||||
[
|
||||
{ label: "Not Passed", value: false, className: "state-fail" },
|
||||
{ label: "Passed", value: true, className: "state-pass" },
|
||||
],
|
||||
!!member.swimTestPassed,
|
||||
async (value) => {
|
||||
try {
|
||||
setStatus(`Updating swim test for ${member.fullName}...`);
|
||||
await setSwimTest(member.id, value);
|
||||
await refreshFamily();
|
||||
setStatus(`Updated swim test for ${member.fullName}.`);
|
||||
} catch (error) {
|
||||
setStatus(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const notRequired = document.createElement("div");
|
||||
notRequired.className = "toggle";
|
||||
notRequired.innerHTML = `<button type="button" class="state-pass active">Swim Test Not Required</button><button type="button">Age ${member.age}</button>`;
|
||||
controls.append(notRequired);
|
||||
}
|
||||
|
||||
card.append(head, controls);
|
||||
return card;
|
||||
}
|
||||
|
||||
async function loadFamily() {
|
||||
const familyId = currentFamilyId();
|
||||
if (!familyId || Number.isNaN(familyId)) {
|
||||
setStatus("Invalid family id.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await api(`/api/families/${familyId}`);
|
||||
const family = payload.item;
|
||||
currentMinSwimTestAge = family.minSwimTestAge;
|
||||
currentGuestPasses = Number.isInteger(family.guestPasses) ? family.guestPasses : 0;
|
||||
|
||||
familyTitle.textContent = `${family.familyName} Family`;
|
||||
familyMeta.textContent = `${family.members.length} members • ${family.primaryPhone || "No phone number on file"}`;
|
||||
ruleBanner.textContent = `Swim test required under age ${currentMinSwimTestAge}.`;
|
||||
renderGuestPasses();
|
||||
|
||||
const adults = family.members.filter((member) => member.age >= 18);
|
||||
const children = family.members.filter((member) => member.age < 18);
|
||||
|
||||
adultsMeta.textContent = `${adults.length} member${adults.length === 1 ? "" : "s"}`;
|
||||
childrenMeta.textContent = `${children.length} member${children.length === 1 ? "" : "s"}`;
|
||||
|
||||
adultsList.innerHTML = "";
|
||||
childrenList.innerHTML = "";
|
||||
|
||||
adults.forEach((member) => {
|
||||
adultsList.append(renderMember(member, refreshFamily));
|
||||
});
|
||||
|
||||
children.forEach((member) => {
|
||||
childrenList.append(renderMember(member, refreshFamily));
|
||||
});
|
||||
|
||||
adultsEmpty.style.display = adults.length ? "none" : "block";
|
||||
childrenEmpty.style.display = children.length ? "none" : "block";
|
||||
}
|
||||
|
||||
async function refreshFamily() {
|
||||
try {
|
||||
await loadFamily();
|
||||
} catch (error) {
|
||||
setStatus(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
refreshBtn.addEventListener("click", refreshFamily);
|
||||
decreasePassBtn.addEventListener("click", () => updateGuestPasses(Math.max(0, currentGuestPasses - 1)));
|
||||
increasePassBtn.addEventListener("click", () => updateGuestPasses(currentGuestPasses + 1));
|
||||
|
||||
refreshFamily();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
727
index.html
Normal file
727
index.html
Normal file
@@ -0,0 +1,727 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cary Swim Club Check-In</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--ink: #10231a;
|
||||
--ink-soft: #4a5e56;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f4fbef;
|
||||
--line: #d8e8cf;
|
||||
--accent: #4cbb17;
|
||||
--accent-strong: #3b9612;
|
||||
--accent-soft: #d8f3c6;
|
||||
--bg-a: #f6fbf2;
|
||||
--bg-b: #deefd1;
|
||||
--hero-a: #1f3e22;
|
||||
--hero-b: #2f642d;
|
||||
--focus-ring: rgba(76, 187, 23, 0.28);
|
||||
--shadow: 0 18px 38px rgba(20, 46, 24, 0.14);
|
||||
--tile-glow: rgba(76, 187, 23, 0.2);
|
||||
}
|
||||
|
||||
body[data-theme="dark"] {
|
||||
--ink: #e7f6df;
|
||||
--ink-soft: #a6c2b0;
|
||||
--surface: #132118;
|
||||
--surface-soft: #1a2d21;
|
||||
--line: #264032;
|
||||
--accent: #65d82c;
|
||||
--accent-strong: #4cbb17;
|
||||
--accent-soft: #24411a;
|
||||
--bg-a: #0a140d;
|
||||
--bg-b: #12211a;
|
||||
--hero-a: #15341c;
|
||||
--hero-b: #1f4c28;
|
||||
--focus-ring: rgba(101, 216, 44, 0.25);
|
||||
--shadow: 0 18px 42px rgba(0, 0, 0, 0.45);
|
||||
--tile-glow: rgba(101, 216, 44, 0.24);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at 82% 12%, var(--tile-glow), transparent 34%),
|
||||
radial-gradient(circle at 12% 88%, rgba(24, 93, 142, 0.2), transparent 36%),
|
||||
linear-gradient(160deg, var(--bg-a), var(--bg-b));
|
||||
background-attachment: fixed;
|
||||
padding: 24px;
|
||||
transition: background 260ms ease, color 260ms ease;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
color: #f4ffe8;
|
||||
background:
|
||||
linear-gradient(120deg, rgba(255, 255, 255, 0.06), transparent 42%),
|
||||
linear-gradient(140deg, var(--hero-a), var(--hero-b));
|
||||
box-shadow: var(--shadow);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 230px;
|
||||
height: 230px;
|
||||
right: -64px;
|
||||
top: -112px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(151, 255, 109, 0.28), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-family: "Archivo Black", "Impact", sans-serif;
|
||||
letter-spacing: 0.03em;
|
||||
font-size: clamp(1.4rem, 2vw + 0.9rem, 2.3rem);
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 8px 0 0;
|
||||
color: #d8f6c8;
|
||||
max-width: 52ch;
|
||||
}
|
||||
|
||||
.club-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(196, 250, 166, 0.4);
|
||||
background: rgba(13, 31, 14, 0.25);
|
||||
padding: 5px 11px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stat {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
box-shadow: var(--shadow);
|
||||
transition: transform 180ms ease, border-color 180ms ease, background 220ms ease;
|
||||
}
|
||||
|
||||
.stat:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, var(--line));
|
||||
}
|
||||
|
||||
.stat .label {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.stat .value {
|
||||
margin: 6px 0 0;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
padding: 18px;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(2px);
|
||||
transition: background 220ms ease, border-color 220ms ease;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 4px 0 14px;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.93rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
padding: 11px 12px;
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
background: var(--surface-soft);
|
||||
transition: border-color 200ms ease, box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.search:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px var(--focus-ring);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-main {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||
color: #fff;
|
||||
transition: transform 160ms ease, filter 160ms ease;
|
||||
}
|
||||
|
||||
.btn-main:hover,
|
||||
.btn-main:focus-visible {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.04);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.checked-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.checked-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
background: linear-gradient(135deg, var(--surface), var(--surface-soft));
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
animation: fade-up 240ms ease both;
|
||||
transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
.checked-card.selected {
|
||||
border-color: color-mix(in srgb, var(--accent) 60%, var(--line));
|
||||
box-shadow: 0 0 0 4px var(--focus-ring);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.checked-name {
|
||||
font-size: 1.08rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checked-meta {
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.83rem;
|
||||
}
|
||||
|
||||
.checked-actions {
|
||||
display: inline-grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.mini-btn {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
padding: 6px 8px;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
background: var(--surface-soft);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.mini-btn.active-in {
|
||||
background: color-mix(in srgb, var(--accent) 24%, var(--surface));
|
||||
color: color-mix(in srgb, var(--accent-strong) 75%, black);
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, var(--line));
|
||||
}
|
||||
|
||||
.mini-btn.active-out {
|
||||
background: #fae8dc;
|
||||
color: #ab5816;
|
||||
border-color: #e2b390;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.family-tile {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-content: space-between;
|
||||
background: linear-gradient(135deg, var(--surface), var(--surface-soft));
|
||||
transition: transform 200ms ease, box-shadow 200ms ease, border-color 200ms ease;
|
||||
box-shadow: 0 8px 24px rgba(14, 43, 73, 0.08);
|
||||
aspect-ratio: 1 / 1;
|
||||
min-height: 180px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.family-tile::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -26px;
|
||||
bottom: -28px;
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, color-mix(in srgb, var(--accent) 30%, transparent), transparent);
|
||||
}
|
||||
|
||||
.family-tile:hover,
|
||||
.family-tile:focus-visible {
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
|
||||
box-shadow: 0 14px 30px rgba(14, 43, 73, 0.14);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.family-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.family-main {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.family-name-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.family-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--accent), #2e8e34);
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.family-tile h3 {
|
||||
margin: 0;
|
||||
font-size: 1.16rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 1rem;
|
||||
color: color-mix(in srgb, var(--accent) 46%, var(--ink-soft));
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin: 2px 0 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.pill-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pill-in {
|
||||
background: color-mix(in srgb, var(--accent) 20%, var(--surface));
|
||||
color: color-mix(in srgb, var(--accent-strong) 78%, black);
|
||||
}
|
||||
|
||||
.pill-kids {
|
||||
background: #e4edff;
|
||||
color: #2456b1;
|
||||
}
|
||||
|
||||
.status-box {
|
||||
font-size: 0.9rem;
|
||||
color: var(--ink-soft);
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(7px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.hidden { display: none; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
body { padding: 14px; }
|
||||
.stats { grid-template-columns: 1fr; }
|
||||
.list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.family-tile { min-height: 150px; }
|
||||
.checked-list { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.list { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<section class="hero">
|
||||
<div class="club-tag">Cary Swim Club</div>
|
||||
<h1>Family Check-In Lookup</h1>
|
||||
<p>Fast and simple member management system for CSC guards and administrators.</p>
|
||||
</section>
|
||||
|
||||
<section class="stats">
|
||||
<article class="stat">
|
||||
<p class="label">Families</p>
|
||||
<p class="value" id="kpiFamilies">0</p>
|
||||
</article>
|
||||
<article class="stat">
|
||||
<p class="label">Total Members</p>
|
||||
<p class="value" id="kpiTotal">0</p>
|
||||
</article>
|
||||
<article class="stat">
|
||||
<p class="label">Checked In</p>
|
||||
<p class="value" id="kpiInPool">0</p>
|
||||
</article>
|
||||
<article class="stat">
|
||||
<p class="label">Children Swim Passed</p>
|
||||
<p class="value" id="kpiSwimKids">0</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Checked-In Right Now</h2>
|
||||
<a class="btn btn-ghost btn-small" href="/manage">Management</a>
|
||||
</div>
|
||||
<p class="sub" id="checkedHelp">Search active guests, or type first/family-last names for quick check-in/out lookup.</p>
|
||||
<div class="controls">
|
||||
<input id="checkedSearch" class="search" type="text" placeholder="Search checked-in members..." autocomplete="off">
|
||||
</div>
|
||||
<div id="checkedList" class="checked-list"></div>
|
||||
<div id="checkedEmpty" class="status-box hidden">No checked-in members found.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Search Families</h2>
|
||||
<p class="sub">Search and tap a family.</p>
|
||||
<div class="controls">
|
||||
<input id="familySearch" class="search" type="text" placeholder="Search Garcia, Nguyen, Mateo..." autocomplete="off">
|
||||
<button id="refreshBtn" class="btn btn-ghost" type="button">Refresh</button>
|
||||
</div>
|
||||
<div id="familyList" class="list"></div>
|
||||
<div id="empty" class="status-box hidden">No matching families found.</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
function getPreferredTheme() {
|
||||
const saved = window.localStorage.getItem("csc-theme");
|
||||
if (saved === "dark" || saved === "light") {
|
||||
return saved;
|
||||
}
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
document.body.setAttribute("data-theme", theme);
|
||||
}
|
||||
|
||||
applyTheme(getPreferredTheme());
|
||||
|
||||
const familySearch = document.getElementById("familySearch");
|
||||
const checkedSearch = document.getElementById("checkedSearch");
|
||||
const familyList = document.getElementById("familyList");
|
||||
const checkedList = document.getElementById("checkedList");
|
||||
const refreshBtn = document.getElementById("refreshBtn");
|
||||
const emptyBox = document.getElementById("empty");
|
||||
const checkedEmpty = document.getElementById("checkedEmpty");
|
||||
const checkedHelp = document.getElementById("checkedHelp");
|
||||
let latestCheckedItems = [];
|
||||
let selectedCheckedIndex = 0;
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...options,
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || payload.message || "Request failed");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function renderFamilies(items) {
|
||||
familyList.innerHTML = "";
|
||||
emptyBox.classList.toggle("hidden", items.length > 0);
|
||||
|
||||
items.forEach((family) => {
|
||||
const checkedInCount = family.members.filter((m) => m.checkedIn).length;
|
||||
const childCount = family.members.filter((m) => m.age < 18).length;
|
||||
const familyInitial = (family.familyName || "F").slice(0, 1).toUpperCase();
|
||||
const node = document.createElement("a");
|
||||
node.className = "family-tile";
|
||||
node.href = `/family/${family.id}`;
|
||||
node.innerHTML = `
|
||||
<div class="family-head">
|
||||
<div class="family-name-wrap">
|
||||
<span class="family-icon">${familyInitial}</span>
|
||||
<h3>${family.familyName} Family</h3>
|
||||
</div>
|
||||
<span class="arrow">View</span>
|
||||
</div>
|
||||
<div class="family-main">
|
||||
<p class="meta">${family.members.length} members • ${family.primaryPhone || "No phone"}</p>
|
||||
<div class="pill-row">
|
||||
<span class="pill pill-in">In: ${checkedInCount}</span>
|
||||
<span class="pill pill-kids">Kids: ${childCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
familyList.append(node);
|
||||
});
|
||||
}
|
||||
|
||||
async function updateCheckStatus(memberId, checkedIn) {
|
||||
try {
|
||||
await api(`/api/members/${memberId}/checkin`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ checkedIn }),
|
||||
});
|
||||
await Promise.all([loadStats(), loadFamilies(), loadCheckedIn()]);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCheckedIn(items) {
|
||||
checkedList.innerHTML = "";
|
||||
latestCheckedItems = items;
|
||||
if (items.length === 0) {
|
||||
selectedCheckedIndex = 0;
|
||||
} else {
|
||||
selectedCheckedIndex = Math.min(selectedCheckedIndex, items.length - 1);
|
||||
}
|
||||
checkedEmpty.classList.toggle("hidden", items.length > 0);
|
||||
const searching = checkedSearch.value.trim().length > 0;
|
||||
checkedHelp.textContent = searching
|
||||
? "Search mode: fuzzy first-name and family-last-name matching. Shortcuts: ArrowUp/ArrowDown to select, Enter=In, Shift+Enter=Out."
|
||||
: "Showing currently checked-in members. Shortcuts work while search box is focused.";
|
||||
|
||||
items.forEach((member, index) => {
|
||||
const node = document.createElement("article");
|
||||
node.className = `checked-card ${(searching && index === selectedCheckedIndex) ? "selected" : ""}`;
|
||||
node.innerHTML = `
|
||||
<div class="checked-name"><span class="dot"></span>${member.fullName}</div>
|
||||
<div class="checked-meta">${member.familyName} • Age ${member.age}</div>
|
||||
<div class="checked-actions">
|
||||
<button class="mini-btn ${member.checkedIn ? "active-in" : ""}" data-state="in">Checked In</button>
|
||||
<button class="mini-btn ${!member.checkedIn ? "active-out" : ""}" data-state="out">Checked Out</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const inBtn = node.querySelector("[data-state='in']");
|
||||
const outBtn = node.querySelector("[data-state='out']");
|
||||
inBtn.disabled = member.checkedIn;
|
||||
outBtn.disabled = !member.checkedIn;
|
||||
|
||||
inBtn.addEventListener("click", async () => {
|
||||
await updateCheckStatus(member.id, true);
|
||||
});
|
||||
outBtn.addEventListener("click", async () => {
|
||||
await updateCheckStatus(member.id, false);
|
||||
});
|
||||
|
||||
checkedList.append(node);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
const stats = await api("/api/dashboard");
|
||||
document.getElementById("kpiFamilies").textContent = stats.families;
|
||||
document.getElementById("kpiTotal").textContent = stats.totalMembers;
|
||||
document.getElementById("kpiInPool").textContent = stats.checkedIn;
|
||||
document.getElementById("kpiSwimKids").textContent = stats.childrenSwimPassed;
|
||||
}
|
||||
|
||||
async function loadFamilies() {
|
||||
const q = familySearch.value.trim();
|
||||
const path = q ? `/api/families?q=${encodeURIComponent(q)}` : "/api/families";
|
||||
const payload = await api(path);
|
||||
renderFamilies(payload.items || []);
|
||||
}
|
||||
|
||||
async function loadCheckedIn() {
|
||||
const q = checkedSearch.value.trim();
|
||||
const path = q ? `/api/checked-in?q=${encodeURIComponent(q)}` : "/api/checked-in";
|
||||
const payload = await api(path);
|
||||
renderCheckedIn(payload.items || []);
|
||||
}
|
||||
|
||||
async function refreshEverything() {
|
||||
try {
|
||||
await Promise.all([loadStats(), loadFamilies(), loadCheckedIn()]);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
familySearch.addEventListener("input", loadFamilies);
|
||||
checkedSearch.addEventListener("input", loadCheckedIn);
|
||||
checkedSearch.addEventListener("keydown", async (event) => {
|
||||
if (latestCheckedItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
selectedCheckedIndex = Math.min(selectedCheckedIndex + 1, latestCheckedItems.length - 1);
|
||||
renderCheckedIn(latestCheckedItems);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
selectedCheckedIndex = Math.max(selectedCheckedIndex - 1, 0);
|
||||
renderCheckedIn(latestCheckedItems);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const selected = latestCheckedItems[selectedCheckedIndex] || latestCheckedItems[0];
|
||||
const targetCheckedIn = !event.shiftKey;
|
||||
await updateCheckStatus(selected.id, targetCheckedIn);
|
||||
});
|
||||
|
||||
refreshBtn.addEventListener("click", refreshEverything);
|
||||
|
||||
refreshEverything();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
833
main.py
Normal file
833
main.py
Normal file
@@ -0,0 +1,833 @@
|
||||
import csv
|
||||
import datetime as dt
|
||||
import difflib
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, jsonify, request, send_file
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
DB_PATH = BASE_DIR / "pool_checkin.sqlite3"
|
||||
BACKUP_DIR = BASE_DIR / "backups"
|
||||
EXPORT_DIR = BASE_DIR / "exports"
|
||||
|
||||
app = Flask(__name__)
|
||||
# Code-only configuration: change this constant to update the swim-test age rule.
|
||||
DEFAULT_MIN_SWIM_TEST_AGE = 13
|
||||
|
||||
|
||||
def dict_row_factory(cursor, row):
|
||||
return {col[0]: row[idx] for idx, col in enumerate(cursor.description)}
|
||||
|
||||
|
||||
def get_connection() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = dict_row_factory
|
||||
conn.execute("PRAGMA foreign_keys = ON;")
|
||||
return conn
|
||||
|
||||
|
||||
def normalize_text(value: str) -> str:
|
||||
return re.sub(r"[^a-z0-9 ]+", "", (value or "").lower()).strip()
|
||||
|
||||
|
||||
def fuzzy_score(query: str, candidate: str) -> float:
|
||||
q = normalize_text(query)
|
||||
c = normalize_text(candidate)
|
||||
if not q or not c:
|
||||
return 0.0
|
||||
|
||||
if q in c:
|
||||
return 1.0 + (len(q) / max(len(c), 1))
|
||||
|
||||
base = difflib.SequenceMatcher(None, q, c).ratio()
|
||||
token_scores = [difflib.SequenceMatcher(None, q, token).ratio() for token in c.split()]
|
||||
token_max = max(token_scores) if token_scores else 0.0
|
||||
return max(base, token_max)
|
||||
|
||||
|
||||
def normalize_phone_for_storage(phone_raw: str) -> str | None:
|
||||
digits = re.sub(r"\D", "", phone_raw or "")
|
||||
if not digits:
|
||||
return None
|
||||
|
||||
if len(digits) == 10:
|
||||
return f"{digits[:3]}-{digits[3:6]}-{digits[6:]}"
|
||||
|
||||
if len(digits) == 11 and digits.startswith("1"):
|
||||
d = digits[1:]
|
||||
return f"+1 {d[:3]}-{d[3:6]}-{d[6:]}"
|
||||
|
||||
raise ValueError("Phone number must include area code (10 digits).")
|
||||
|
||||
|
||||
def initialize_database() -> None:
|
||||
BACKUP_DIR.mkdir(exist_ok=True)
|
||||
EXPORT_DIR.mkdir(exist_ok=True)
|
||||
|
||||
with get_connection() as conn:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS families (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
family_name TEXT NOT NULL,
|
||||
primary_phone TEXT,
|
||||
guest_passes INTEGER NOT NULL DEFAULT 0 CHECK(guest_passes >= 0),
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
age INTEGER NOT NULL CHECK(age >= 0),
|
||||
family_id INTEGER,
|
||||
is_checked_in INTEGER NOT NULL DEFAULT 0 CHECK(is_checked_in IN (0, 1)),
|
||||
swim_test_passed INTEGER NOT NULL DEFAULT 0 CHECK(swim_test_passed IN (0, 1)),
|
||||
can_use_deep_end INTEGER NOT NULL DEFAULT 0 CHECK(can_use_deep_end IN (0, 1)),
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (family_id) REFERENCES families(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_members_name ON members(last_name, first_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_members_family ON members(family_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_members_checked_in ON members(is_checked_in);
|
||||
"""
|
||||
)
|
||||
|
||||
# Seed demo data once to make first run usable.
|
||||
total_rows = conn.execute("SELECT COUNT(*) AS count FROM members;").fetchone()["count"]
|
||||
if total_rows == 0:
|
||||
conn.executemany(
|
||||
"INSERT INTO families (family_name, primary_phone) VALUES (?, ?);",
|
||||
[
|
||||
("Garcia", "555-201-0141"),
|
||||
("Miller", "555-201-0187"),
|
||||
("Nguyen", "555-201-0179"),
|
||||
],
|
||||
)
|
||||
|
||||
family_map = {
|
||||
row["family_name"]: row["id"]
|
||||
for row in conn.execute("SELECT id, family_name FROM families;").fetchall()
|
||||
}
|
||||
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO members
|
||||
(first_name, last_name, age, family_id, is_checked_in, swim_test_passed, can_use_deep_end)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?);
|
||||
""",
|
||||
[
|
||||
("Avery", "Wilson", 34, None, 0, 0, 1),
|
||||
("Noah", "Patel", 42, None, 0, 0, 1),
|
||||
("Emma", "James", 29, None, 0, 0, 1),
|
||||
("Luis", "Garcia", 41, family_map["Garcia"], 0, 0, 1),
|
||||
("Sofia", "Garcia", 38, family_map["Garcia"], 0, 0, 1),
|
||||
("Mateo", "Garcia", 11, family_map["Garcia"], 0, 0, 0),
|
||||
("Helen", "Miller", 37, family_map["Miller"], 0, 0, 1),
|
||||
("Kai", "Miller", 9, family_map["Miller"], 0, 0, 0),
|
||||
("Trang", "Nguyen", 35, family_map["Nguyen"], 0, 0, 1),
|
||||
("Liam", "Nguyen", 8, family_map["Nguyen"], 0, 0, 0),
|
||||
],
|
||||
)
|
||||
|
||||
schema_version = conn.execute(
|
||||
"SELECT value FROM app_meta WHERE key = 'schema_version';"
|
||||
).fetchone()
|
||||
current_schema_version = 0
|
||||
if schema_version is not None:
|
||||
try:
|
||||
current_schema_version = int(schema_version["value"])
|
||||
except (TypeError, ValueError):
|
||||
current_schema_version = 0
|
||||
|
||||
# One-time migration for older local databases created before defaults were reset.
|
||||
if current_schema_version < 2:
|
||||
conn.execute("UPDATE members SET is_checked_in = 0, swim_test_passed = 0;")
|
||||
conn.execute(
|
||||
"UPDATE members SET can_use_deep_end = CASE WHEN age >= 13 THEN 1 ELSE 0 END;"
|
||||
)
|
||||
|
||||
family_columns = {
|
||||
row["name"]
|
||||
for row in conn.execute("PRAGMA table_info(families);").fetchall()
|
||||
}
|
||||
if "guest_passes" not in family_columns:
|
||||
conn.execute(
|
||||
"ALTER TABLE families "
|
||||
"ADD COLUMN guest_passes INTEGER NOT NULL DEFAULT 0 CHECK(guest_passes >= 0);"
|
||||
)
|
||||
|
||||
if current_schema_version < 3:
|
||||
conn.execute(
|
||||
"INSERT INTO app_meta (key, value) VALUES ('schema_version', '3') "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value;"
|
||||
)
|
||||
|
||||
# Normalize older short phone records to full numbers when possible.
|
||||
old_phones = conn.execute(
|
||||
"SELECT id, primary_phone FROM families WHERE primary_phone IS NOT NULL;"
|
||||
).fetchall()
|
||||
for row in old_phones:
|
||||
phone = row["primary_phone"]
|
||||
digits = re.sub(r"\D", "", phone)
|
||||
if len(digits) == 7:
|
||||
normalized = normalize_phone_for_storage(f"555{digits}")
|
||||
conn.execute(
|
||||
"UPDATE families SET primary_phone = ? WHERE id = ?;",
|
||||
(normalized, row["id"]),
|
||||
)
|
||||
|
||||
|
||||
def to_member_payload(row: dict, min_swim_test_age: int) -> dict:
|
||||
requires_swim_test = row["age"] < min_swim_test_age
|
||||
return {
|
||||
"id": row["id"],
|
||||
"firstName": row["first_name"],
|
||||
"lastName": row["last_name"],
|
||||
"fullName": f"{row['first_name']} {row['last_name']}",
|
||||
"age": row["age"],
|
||||
"familyId": row["family_id"],
|
||||
"checkedIn": bool(row["is_checked_in"]),
|
||||
"swimTestPassed": bool(row["swim_test_passed"]),
|
||||
"requiresSwimTest": requires_swim_test,
|
||||
}
|
||||
|
||||
|
||||
def recalculate_deep_end(conn: sqlite3.Connection, member_id: int) -> None:
|
||||
member = conn.execute(
|
||||
"SELECT age, swim_test_passed FROM members WHERE id = ?;",
|
||||
(member_id,),
|
||||
).fetchone()
|
||||
if member is None:
|
||||
return
|
||||
|
||||
can_use = int(member["age"] >= 13 or bool(member["swim_test_passed"]))
|
||||
conn.execute(
|
||||
"UPDATE members SET can_use_deep_end = ? WHERE id = ?;",
|
||||
(can_use, member_id),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def homepage():
|
||||
return send_file(BASE_DIR / "index.html")
|
||||
|
||||
|
||||
@app.route("/family/<int:family_id>")
|
||||
def family_page(family_id: int):
|
||||
# The family id is consumed client-side from the URL path.
|
||||
return send_file(BASE_DIR / "family.html")
|
||||
|
||||
|
||||
@app.route("/manage")
|
||||
def manage_page():
|
||||
return send_file(BASE_DIR / "manage.html")
|
||||
|
||||
|
||||
@app.route("/api/dashboard", methods=["GET"])
|
||||
def api_dashboard():
|
||||
with get_connection() as conn:
|
||||
totals = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) AS total_members,
|
||||
SUM(CASE WHEN family_id IS NULL THEN 1 ELSE 0 END) AS individual_members,
|
||||
SUM(CASE WHEN is_checked_in = 1 THEN 1 ELSE 0 END) AS checked_in_members,
|
||||
SUM(CASE WHEN age < ? THEN 1 ELSE 0 END) AS children_count,
|
||||
SUM(CASE WHEN age < ? AND swim_test_passed = 1 THEN 1 ELSE 0 END) AS children_swim_test_passed
|
||||
FROM members;
|
||||
"""
|
||||
,
|
||||
(DEFAULT_MIN_SWIM_TEST_AGE, DEFAULT_MIN_SWIM_TEST_AGE),
|
||||
).fetchone()
|
||||
|
||||
family_count = conn.execute("SELECT COUNT(*) AS count FROM families;").fetchone()["count"]
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"totalMembers": totals["total_members"] or 0,
|
||||
"individuals": totals["individual_members"] or 0,
|
||||
"families": family_count,
|
||||
"checkedIn": totals["checked_in_members"] or 0,
|
||||
"children": totals["children_count"] or 0,
|
||||
"childrenSwimPassed": totals["children_swim_test_passed"] or 0,
|
||||
"minSwimTestAge": DEFAULT_MIN_SWIM_TEST_AGE,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/checked-in", methods=["GET"])
|
||||
def api_checked_in_members():
|
||||
query = request.args.get("q", "").strip()
|
||||
has_query = bool(query)
|
||||
threshold = 0.62
|
||||
query_norm = normalize_text(query)
|
||||
|
||||
with get_connection() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
m.id,
|
||||
m.first_name,
|
||||
m.last_name,
|
||||
m.age,
|
||||
m.family_id,
|
||||
m.is_checked_in,
|
||||
m.swim_test_passed,
|
||||
f.family_name
|
||||
FROM members m
|
||||
LEFT JOIN families f ON m.family_id = f.id
|
||||
ORDER BY m.last_name, m.first_name;
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
family_last_name_score = {}
|
||||
if has_query:
|
||||
for row in rows:
|
||||
family_id = row["family_id"]
|
||||
if family_id is None:
|
||||
continue
|
||||
score = fuzzy_score(query, row["last_name"])
|
||||
current = family_last_name_score.get(family_id, 0.0)
|
||||
family_last_name_score[family_id] = max(current, score)
|
||||
|
||||
candidates = []
|
||||
for row in rows:
|
||||
checked_in = bool(row["is_checked_in"])
|
||||
if not has_query and not checked_in:
|
||||
continue
|
||||
|
||||
first_score = fuzzy_score(query, row["first_name"]) if has_query else 1.0
|
||||
family_score = family_last_name_score.get(row["family_id"], 0.0) if has_query else 0.0
|
||||
last_score = fuzzy_score(query, row["last_name"]) if has_query else 0.0
|
||||
|
||||
matches_query = (
|
||||
not has_query
|
||||
or first_score >= threshold
|
||||
or family_score >= threshold
|
||||
or (row["family_id"] is None and last_score >= threshold)
|
||||
)
|
||||
if not matches_query:
|
||||
continue
|
||||
|
||||
full_name = f"{row['first_name']} {row['last_name']}"
|
||||
family_name = row["family_name"] if row["family_name"] else "Individual"
|
||||
first_norm = normalize_text(row["first_name"])
|
||||
last_norm = normalize_text(row["last_name"])
|
||||
full_norm = normalize_text(full_name)
|
||||
|
||||
# Ordered matching boosts: exact > prefix > early-position > fuzzy.
|
||||
prefix_bonus = 0.0
|
||||
position_bonus = 0.0
|
||||
if has_query and query_norm:
|
||||
if first_norm == query_norm:
|
||||
prefix_bonus = 1.6
|
||||
elif first_norm.startswith(query_norm):
|
||||
prefix_bonus = 1.25
|
||||
elif full_norm.startswith(query_norm):
|
||||
prefix_bonus = 1.05
|
||||
|
||||
if query_norm in first_norm:
|
||||
position_bonus = max(position_bonus, 0.35 - (first_norm.find(query_norm) * 0.03))
|
||||
if query_norm in full_norm:
|
||||
position_bonus = max(position_bonus, 0.2 - (full_norm.find(query_norm) * 0.015))
|
||||
|
||||
family_boost = family_score * 0.85
|
||||
match_score = max(first_score + prefix_bonus + position_bonus, last_score, family_boost)
|
||||
candidates.append(
|
||||
{
|
||||
"id": row["id"],
|
||||
"firstName": row["first_name"],
|
||||
"lastName": row["last_name"],
|
||||
"fullName": full_name,
|
||||
"age": row["age"],
|
||||
"checkedIn": checked_in,
|
||||
"familyName": family_name,
|
||||
"requiresSwimTest": row["age"] < DEFAULT_MIN_SWIM_TEST_AGE,
|
||||
"swimTestPassed": bool(row["swim_test_passed"]),
|
||||
"matchScore": round(match_score, 4),
|
||||
"firstScore": round(first_score, 4),
|
||||
"familyScore": round(family_score, 4),
|
||||
"lastScore": round(last_score, 4),
|
||||
}
|
||||
)
|
||||
|
||||
if has_query:
|
||||
candidates.sort(
|
||||
key=lambda item: (
|
||||
-item["matchScore"],
|
||||
-item["firstScore"],
|
||||
-item["familyScore"],
|
||||
not item["checkedIn"],
|
||||
item["firstName"].lower(),
|
||||
item["lastName"].lower(),
|
||||
)
|
||||
)
|
||||
else:
|
||||
candidates.sort(
|
||||
key=lambda item: (
|
||||
not item["checkedIn"],
|
||||
item["lastName"].lower(),
|
||||
item["firstName"].lower(),
|
||||
)
|
||||
)
|
||||
|
||||
return jsonify({"items": candidates})
|
||||
|
||||
|
||||
@app.route("/api/individuals", methods=["GET"])
|
||||
def api_individuals():
|
||||
query = request.args.get("q", "").strip().lower()
|
||||
|
||||
with get_connection() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, first_name, last_name, age, family_id, is_checked_in, swim_test_passed, can_use_deep_end
|
||||
FROM members
|
||||
WHERE family_id IS NULL
|
||||
ORDER BY last_name, first_name;
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
filtered = []
|
||||
for row in rows:
|
||||
payload = to_member_payload(row, DEFAULT_MIN_SWIM_TEST_AGE)
|
||||
searchable = f"{payload['fullName']} {payload['id']}".lower()
|
||||
if query and query not in searchable:
|
||||
continue
|
||||
filtered.append(payload)
|
||||
|
||||
return jsonify({"items": filtered})
|
||||
|
||||
|
||||
@app.route("/api/families", methods=["GET", "POST"])
|
||||
def api_families():
|
||||
if request.method == "POST":
|
||||
body = request.get_json(silent=True) or {}
|
||||
family_name = (body.get("familyName") or "").strip()
|
||||
primary_phone = (body.get("primaryPhone") or "").strip()
|
||||
|
||||
if not family_name:
|
||||
return jsonify({"error": "`familyName` is required."}), 400
|
||||
|
||||
try:
|
||||
normalized_phone = normalize_phone_for_storage(primary_phone)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
with get_connection() as conn:
|
||||
result = conn.execute(
|
||||
"INSERT INTO families (family_name, primary_phone, guest_passes) VALUES (?, ?, 0);",
|
||||
(family_name, normalized_phone),
|
||||
)
|
||||
|
||||
family = conn.execute(
|
||||
"SELECT id, family_name, primary_phone, guest_passes FROM families WHERE id = ?;",
|
||||
(result.lastrowid,),
|
||||
).fetchone()
|
||||
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"item": {
|
||||
"id": family["id"],
|
||||
"familyName": family["family_name"],
|
||||
"primaryPhone": family["primary_phone"],
|
||||
"guestPasses": family["guest_passes"],
|
||||
}
|
||||
}
|
||||
),
|
||||
201,
|
||||
)
|
||||
|
||||
query = request.args.get("q", "").strip().lower()
|
||||
|
||||
with get_connection() as conn:
|
||||
families = conn.execute(
|
||||
"SELECT id, family_name, primary_phone, guest_passes FROM families ORDER BY family_name;"
|
||||
).fetchall()
|
||||
|
||||
members = conn.execute(
|
||||
"""
|
||||
SELECT id, first_name, last_name, age, family_id, is_checked_in, swim_test_passed, can_use_deep_end
|
||||
FROM members
|
||||
WHERE family_id IS NOT NULL
|
||||
ORDER BY age DESC, last_name, first_name;
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
members_by_family = {}
|
||||
for row in members:
|
||||
item = to_member_payload(row, DEFAULT_MIN_SWIM_TEST_AGE)
|
||||
members_by_family.setdefault(item["familyId"], []).append(item)
|
||||
|
||||
payload = []
|
||||
for family in families:
|
||||
family_members = members_by_family.get(family["id"], [])
|
||||
family_name = family["family_name"]
|
||||
score = 0.0
|
||||
if query:
|
||||
candidates = [family_name] + [m["fullName"] for m in family_members]
|
||||
score = max((fuzzy_score(query, candidate) for candidate in candidates), default=0.0)
|
||||
if score < 0.42:
|
||||
continue
|
||||
else:
|
||||
score = 1.0
|
||||
|
||||
payload.append(
|
||||
{
|
||||
"id": family["id"],
|
||||
"familyName": family_name,
|
||||
"primaryPhone": family["primary_phone"],
|
||||
"guestPasses": family["guest_passes"],
|
||||
"members": family_members,
|
||||
"matchScore": round(score, 4),
|
||||
}
|
||||
)
|
||||
|
||||
payload.sort(key=lambda item: (-item["matchScore"], item["familyName"].lower()))
|
||||
|
||||
return jsonify({"items": payload})
|
||||
|
||||
|
||||
@app.route("/api/members", methods=["POST"])
|
||||
def api_create_member():
|
||||
body = request.get_json(silent=True) or {}
|
||||
first_name = (body.get("firstName") or "").strip()
|
||||
last_name = (body.get("lastName") or "").strip()
|
||||
age = body.get("age")
|
||||
family_id = body.get("familyId")
|
||||
|
||||
if not first_name or not last_name:
|
||||
return jsonify({"error": "`firstName` and `lastName` are required."}), 400
|
||||
|
||||
if not isinstance(age, int) or age < 0:
|
||||
return jsonify({"error": "`age` must be a non-negative integer."}), 400
|
||||
|
||||
if family_id is None:
|
||||
return jsonify({"error": "`familyId` is required for this form."}), 400
|
||||
|
||||
if not isinstance(family_id, int):
|
||||
return jsonify({"error": "`familyId` must be an integer."}), 400
|
||||
|
||||
with get_connection() as conn:
|
||||
family = conn.execute(
|
||||
"SELECT id FROM families WHERE id = ?;",
|
||||
(family_id,),
|
||||
).fetchone()
|
||||
if family is None:
|
||||
return jsonify({"error": "Family not found."}), 404
|
||||
|
||||
seed_can_use_deep_end = int(age >= DEFAULT_MIN_SWIM_TEST_AGE)
|
||||
result = conn.execute(
|
||||
"""
|
||||
INSERT INTO members
|
||||
(first_name, last_name, age, family_id, is_checked_in, swim_test_passed, can_use_deep_end)
|
||||
VALUES (?, ?, ?, ?, 0, 0, ?);
|
||||
""",
|
||||
(first_name, last_name, age, family_id, seed_can_use_deep_end),
|
||||
)
|
||||
|
||||
member = conn.execute(
|
||||
"""
|
||||
SELECT id, first_name, last_name, age, family_id, is_checked_in, swim_test_passed, can_use_deep_end
|
||||
FROM members
|
||||
WHERE id = ?;
|
||||
""",
|
||||
(result.lastrowid,),
|
||||
).fetchone()
|
||||
|
||||
return jsonify({"member": to_member_payload(member, DEFAULT_MIN_SWIM_TEST_AGE)}), 201
|
||||
|
||||
|
||||
@app.route("/api/families/<int:family_id>", methods=["GET"])
|
||||
def api_family_detail(family_id: int):
|
||||
with get_connection() as conn:
|
||||
family = conn.execute(
|
||||
"SELECT id, family_name, primary_phone, guest_passes FROM families WHERE id = ?;",
|
||||
(family_id,),
|
||||
).fetchone()
|
||||
if family is None:
|
||||
return jsonify({"error": "Family not found."}), 404
|
||||
|
||||
member_rows = conn.execute(
|
||||
"""
|
||||
SELECT id, first_name, last_name, age, family_id, is_checked_in, swim_test_passed, can_use_deep_end
|
||||
FROM members
|
||||
WHERE family_id = ?
|
||||
ORDER BY age DESC, last_name, first_name;
|
||||
""",
|
||||
(family_id,),
|
||||
).fetchall()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"item": {
|
||||
"id": family["id"],
|
||||
"familyName": family["family_name"],
|
||||
"primaryPhone": family["primary_phone"],
|
||||
"guestPasses": family["guest_passes"],
|
||||
"minSwimTestAge": DEFAULT_MIN_SWIM_TEST_AGE,
|
||||
"members": [to_member_payload(row, DEFAULT_MIN_SWIM_TEST_AGE) for row in member_rows],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/families/<int:family_id>/guest-passes", methods=["PATCH"])
|
||||
def api_update_family_guest_passes(family_id: int):
|
||||
body = request.get_json(silent=True) or {}
|
||||
guest_passes = body.get("guestPasses")
|
||||
|
||||
if not isinstance(guest_passes, int) or guest_passes < 0:
|
||||
return jsonify({"error": "`guestPasses` must be a non-negative integer."}), 400
|
||||
|
||||
with get_connection() as conn:
|
||||
result = conn.execute(
|
||||
"UPDATE families SET guest_passes = ? WHERE id = ?;",
|
||||
(guest_passes, family_id),
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
return jsonify({"error": "Family not found."}), 404
|
||||
|
||||
family = conn.execute(
|
||||
"SELECT id, family_name, primary_phone, guest_passes FROM families WHERE id = ?;",
|
||||
(family_id,),
|
||||
).fetchone()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"item": {
|
||||
"id": family["id"],
|
||||
"familyName": family["family_name"],
|
||||
"primaryPhone": family["primary_phone"],
|
||||
"guestPasses": family["guest_passes"],
|
||||
},
|
||||
"message": "Guest passes updated.",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/families/<int:family_id>", methods=["DELETE"])
|
||||
def api_delete_family(family_id: int):
|
||||
with get_connection() as conn:
|
||||
family = conn.execute(
|
||||
"SELECT id, family_name FROM families WHERE id = ?;",
|
||||
(family_id,),
|
||||
).fetchone()
|
||||
if family is None:
|
||||
return jsonify({"error": "Family not found."}), 404
|
||||
|
||||
deleted_members = conn.execute(
|
||||
"DELETE FROM members WHERE family_id = ?;",
|
||||
(family_id,),
|
||||
).rowcount
|
||||
|
||||
conn.execute("DELETE FROM families WHERE id = ?;", (family_id,))
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"message": (
|
||||
f"Deleted {family['family_name']} family and {deleted_members} member"
|
||||
f"{'s' if deleted_members != 1 else ''}."
|
||||
),
|
||||
"deletedFamilyId": family_id,
|
||||
"deletedMembers": deleted_members,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/members/<int:member_id>/checkin", methods=["PATCH"])
|
||||
def api_update_checkin(member_id: int):
|
||||
body = request.get_json(silent=True) or {}
|
||||
checked_in = body.get("checkedIn")
|
||||
if not isinstance(checked_in, bool):
|
||||
return jsonify({"error": "`checkedIn` must be a boolean."}), 400
|
||||
|
||||
with get_connection() as conn:
|
||||
result = conn.execute(
|
||||
"UPDATE members SET is_checked_in = ? WHERE id = ?;",
|
||||
(int(checked_in), member_id),
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
return jsonify({"error": "Member not found."}), 404
|
||||
|
||||
member = conn.execute(
|
||||
"""
|
||||
SELECT id, first_name, last_name, age, family_id, is_checked_in, swim_test_passed, can_use_deep_end
|
||||
FROM members
|
||||
WHERE id = ?;
|
||||
""",
|
||||
(member_id,),
|
||||
).fetchone()
|
||||
|
||||
return jsonify({"member": to_member_payload(member, DEFAULT_MIN_SWIM_TEST_AGE)})
|
||||
|
||||
|
||||
@app.route("/api/members/<int:member_id>/swim-test", methods=["PATCH"])
|
||||
def api_update_swim_test(member_id: int):
|
||||
body = request.get_json(silent=True) or {}
|
||||
passed = body.get("swimTestPassed")
|
||||
if not isinstance(passed, bool):
|
||||
return jsonify({"error": "`swimTestPassed` must be a boolean."}), 400
|
||||
|
||||
with get_connection() as conn:
|
||||
result = conn.execute(
|
||||
"UPDATE members SET swim_test_passed = ? WHERE id = ?;",
|
||||
(int(passed), member_id),
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
return jsonify({"error": "Member not found."}), 404
|
||||
|
||||
member = conn.execute(
|
||||
"""
|
||||
SELECT id, first_name, last_name, age, family_id, is_checked_in, swim_test_passed, can_use_deep_end
|
||||
FROM members
|
||||
WHERE id = ?;
|
||||
""",
|
||||
(member_id,),
|
||||
).fetchone()
|
||||
|
||||
return jsonify({"member": to_member_payload(member, DEFAULT_MIN_SWIM_TEST_AGE)})
|
||||
|
||||
|
||||
@app.route("/api/admin/backup", methods=["POST"])
|
||||
def api_admin_backup():
|
||||
stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_path = BACKUP_DIR / f"pool_backup_{stamp}.sqlite3"
|
||||
shutil.copy2(DB_PATH, backup_path)
|
||||
return jsonify({"message": f"Backup created at {backup_path.name}"})
|
||||
|
||||
|
||||
@app.route("/api/admin/export", methods=["POST"])
|
||||
def api_admin_export():
|
||||
stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
csv_path = EXPORT_DIR / f"pool_members_{stamp}.csv"
|
||||
|
||||
with get_connection() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
m.id,
|
||||
m.first_name,
|
||||
m.last_name,
|
||||
m.age,
|
||||
m.family_id,
|
||||
COALESCE(f.family_name, 'Individual') AS family_name,
|
||||
COALESCE(f.primary_phone, '') AS primary_phone,
|
||||
m.is_checked_in,
|
||||
m.swim_test_passed
|
||||
FROM members m
|
||||
LEFT JOIN families f ON m.family_id = f.id
|
||||
ORDER BY family_name, m.last_name, m.first_name;
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
with csv_path.open("w", newline="", encoding="utf-8") as csv_file:
|
||||
writer = csv.writer(csv_file)
|
||||
writer.writerow(
|
||||
[
|
||||
"member_id",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"age",
|
||||
"family_id",
|
||||
"family_name",
|
||||
"primary_phone",
|
||||
"checked_in",
|
||||
"swim_test_passed",
|
||||
"requires_swim_test",
|
||||
]
|
||||
)
|
||||
for row in rows:
|
||||
writer.writerow(
|
||||
[
|
||||
row["id"],
|
||||
row["first_name"],
|
||||
row["last_name"],
|
||||
row["age"],
|
||||
row["family_id"],
|
||||
row["family_name"],
|
||||
row["primary_phone"],
|
||||
row["is_checked_in"],
|
||||
row["swim_test_passed"],
|
||||
int(row["age"] < DEFAULT_MIN_SWIM_TEST_AGE),
|
||||
]
|
||||
)
|
||||
|
||||
return jsonify({"message": f"Export generated: {csv_path.name}"})
|
||||
|
||||
|
||||
@app.route("/api/admin/reindex", methods=["POST"])
|
||||
def api_admin_reindex():
|
||||
with get_connection() as conn:
|
||||
conn.execute("REINDEX;")
|
||||
return jsonify({"message": "SQLite indexes rebuilt successfully."})
|
||||
|
||||
|
||||
@app.route("/api/admin/vacuum", methods=["POST"])
|
||||
def api_admin_vacuum():
|
||||
# VACUUM requires autocommit mode in SQLite.
|
||||
conn = sqlite3.connect(DB_PATH, isolation_level=None)
|
||||
try:
|
||||
conn.execute("VACUUM;")
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify({"message": "VACUUM completed successfully."})
|
||||
|
||||
|
||||
@app.route("/api/admin/reset-checkins", methods=["POST"])
|
||||
def api_admin_reset_checkins():
|
||||
with get_connection() as conn:
|
||||
conn.execute("UPDATE members SET is_checked_in = 0;")
|
||||
return jsonify({"message": "All members were checked out."})
|
||||
|
||||
|
||||
@app.route("/api/admin/reset-swim-tests", methods=["POST"])
|
||||
def api_admin_reset_swim_tests():
|
||||
with get_connection() as conn:
|
||||
conn.execute("UPDATE members SET swim_test_passed = 0;")
|
||||
conn.execute(
|
||||
"UPDATE members SET can_use_deep_end = CASE WHEN age >= ? THEN 1 ELSE 0 END;",
|
||||
(DEFAULT_MIN_SWIM_TEST_AGE,),
|
||||
)
|
||||
return jsonify({"message": "All swim tests were reset to not passed."})
|
||||
|
||||
|
||||
@app.route("/api/admin/cleanup-orphan-members", methods=["POST"])
|
||||
def api_admin_cleanup_orphan_members():
|
||||
with get_connection() as conn:
|
||||
deleted = conn.execute(
|
||||
"""
|
||||
DELETE FROM members
|
||||
WHERE family_id IS NULL
|
||||
OR family_id NOT IN (SELECT id FROM families);
|
||||
"""
|
||||
).rowcount
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"message": (
|
||||
f"Removed {deleted} member"
|
||||
f"{'s' if deleted != 1 else ''} with null or missing family ids."
|
||||
),
|
||||
"deletedMembers": deleted,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
initialize_database()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=False)
|
||||
592
manage.html
Normal file
592
manage.html
Normal file
@@ -0,0 +1,592 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cary Swim Club Management</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--ink: #10231a;
|
||||
--ink-soft: #4b5f56;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f4fbef;
|
||||
--line: #d8e8cf;
|
||||
--accent: #4cbb17;
|
||||
--accent-strong: #3b9612;
|
||||
--danger: #b63838;
|
||||
--bg-a: #f6fbf2;
|
||||
--bg-b: #deefd1;
|
||||
--hero-a: #1f3e22;
|
||||
--hero-b: #2f642d;
|
||||
--focus-ring: rgba(76, 187, 23, 0.28);
|
||||
--shadow: 0 14px 34px rgba(17, 43, 20, 0.14);
|
||||
}
|
||||
|
||||
body[data-theme="dark"] {
|
||||
--ink: #e7f6df;
|
||||
--ink-soft: #a5c1af;
|
||||
--surface: #132118;
|
||||
--surface-soft: #1a2d21;
|
||||
--line: #264032;
|
||||
--accent: #65d82c;
|
||||
--accent-strong: #4cbb17;
|
||||
--danger: #dc5959;
|
||||
--bg-a: #0a140d;
|
||||
--bg-b: #12211a;
|
||||
--hero-a: #15341c;
|
||||
--hero-b: #1f4c28;
|
||||
--focus-ring: rgba(101, 216, 44, 0.25);
|
||||
--shadow: 0 18px 40px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at 78% 10%, rgba(76, 187, 23, 0.22), transparent 32%),
|
||||
radial-gradient(circle at 10% 85%, rgba(61, 120, 67, 0.16), transparent 36%),
|
||||
linear-gradient(160deg, var(--bg-a), var(--bg-b));
|
||||
background-attachment: fixed;
|
||||
padding: 20px;
|
||||
transition: background 260ms ease, color 260ms ease;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
background:
|
||||
linear-gradient(120deg, rgba(255, 255, 255, 0.05), transparent 42%),
|
||||
linear-gradient(140deg, var(--hero-a), var(--hero-b));
|
||||
color: #f2ffe9;
|
||||
box-shadow: var(--shadow);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
right: -70px;
|
||||
top: -105px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(151, 255, 109, 0.24), transparent 70%);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-family: "Archivo Black", "Impact", sans-serif;
|
||||
font-size: clamp(1.3rem, 2vw + 1rem, 2rem);
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 8px 0 0;
|
||||
color: #d8f6c8;
|
||||
}
|
||||
|
||||
.club-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(196, 250, 166, 0.4);
|
||||
background: rgba(13, 31, 14, 0.25);
|
||||
padding: 5px 11px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 0;
|
||||
border-radius: 11px;
|
||||
padding: 9px 13px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-main {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: var(--surface-soft);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c44d4d, var(--danger));
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
box-shadow: var(--shadow);
|
||||
transition: background 220ms ease, border-color 220ms ease;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0;
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 6px 0 10px;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 0.84rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 9px 10px;
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px var(--focus-ring);
|
||||
}
|
||||
|
||||
.theme-panel {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: var(--surface-soft);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.theme-label {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.theme-label strong {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.theme-label span {
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--surface-soft) 85%, transparent);
|
||||
padding: 3px;
|
||||
display: inline-grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 3px;
|
||||
min-width: 170px;
|
||||
}
|
||||
|
||||
.theme-toggle button {
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
padding: 8px 10px;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
color: var(--ink);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.theme-toggle button.active {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 20px rgba(9, 35, 15, 0.26);
|
||||
}
|
||||
|
||||
.action-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.status {
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 11px;
|
||||
padding: 10px 11px;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.88rem;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.danger-zone {
|
||||
border-color: color-mix(in srgb, var(--danger) 35%, var(--line));
|
||||
}
|
||||
|
||||
.danger-zone .sub {
|
||||
color: color-mix(in srgb, var(--danger) 78%, var(--ink-soft));
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
body { padding: 12px; }
|
||||
.row { grid-template-columns: 1fr; }
|
||||
.action-list { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<section class="hero">
|
||||
<div class="club-tag">Cary Swim Club</div>
|
||||
<h1>Management</h1>
|
||||
<p>Add families, add members, and run core administrative tasks.</p>
|
||||
</section>
|
||||
|
||||
<section class="toolbar">
|
||||
<a class="btn btn-ghost" href="/">Back To Family Search</a>
|
||||
<a class="btn btn-ghost" id="openFirstFamily" href="#">Open First Family</a>
|
||||
</section>
|
||||
|
||||
<section class="theme-panel" aria-label="Theme controls">
|
||||
<div class="theme-label">
|
||||
<strong>Display Theme</strong>
|
||||
<span>Switch between daylight deck mode and evening mode.</span>
|
||||
</div>
|
||||
<div class="theme-toggle" role="group" aria-label="Color theme selection">
|
||||
<button id="themeLightBtn" type="button">Light</button>
|
||||
<button id="themeDarkBtn" type="button">Dark</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<article class="panel">
|
||||
<h2>Add Family</h2>
|
||||
<p class="sub">Create a new household record.</p>
|
||||
<form id="familyForm" class="form">
|
||||
<label>
|
||||
Family Name
|
||||
<input id="familyName" type="text" required placeholder="Garcia">
|
||||
</label>
|
||||
<label>
|
||||
Primary Phone
|
||||
<input id="familyPhone" type="tel" placeholder="555-201-0141">
|
||||
</label>
|
||||
<button class="btn btn-main" type="submit">Add Family</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<h2>Add Member To Family</h2>
|
||||
<p class="sub">Create an individual member attached to a family.</p>
|
||||
<form id="memberForm" class="form">
|
||||
<div class="row">
|
||||
<label>
|
||||
First Name
|
||||
<input id="memberFirst" type="text" required placeholder="Sofia">
|
||||
</label>
|
||||
<label>
|
||||
Last Name
|
||||
<input id="memberLast" type="text" required placeholder="Garcia">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>
|
||||
Age
|
||||
<input id="memberAge" type="number" min="0" required>
|
||||
</label>
|
||||
<label>
|
||||
Family
|
||||
<select id="memberFamily" required></select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-main" type="submit">Add Member</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="panel danger-zone">
|
||||
<h2>Family Danger Zone</h2>
|
||||
<p class="sub">Delete a family and all members inside it.</p>
|
||||
<div class="form">
|
||||
<label>
|
||||
Family To Delete
|
||||
<select id="deleteFamilySelect"></select>
|
||||
</label>
|
||||
<button id="deleteFamilyBtn" class="btn btn-danger" type="button">Delete Family + Members</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel" style="grid-column: 1 / -1;">
|
||||
<h2>Administrative Actions</h2>
|
||||
<p class="sub">Database maintenance and operational actions.</p>
|
||||
<div class="action-list">
|
||||
<button class="btn btn-ghost" data-action="/api/admin/backup">Create Backup</button>
|
||||
<button class="btn btn-ghost" data-action="/api/admin/export">Export CSV</button>
|
||||
<button class="btn btn-ghost" data-action="/api/admin/reindex">Rebuild Indexes</button>
|
||||
<button class="btn btn-ghost" data-action="/api/admin/vacuum">Run VACUUM</button>
|
||||
<button class="btn btn-danger" data-action="/api/admin/cleanup-orphan-members" data-confirm="Delete all members with null or missing family ids? This cannot be undone.">Cleanup Orphan Members</button>
|
||||
<button class="btn btn-danger" data-action="/api/admin/reset-checkins" data-confirm="Check out all members right now?">Check Out Everyone</button>
|
||||
<button class="btn btn-danger" data-action="/api/admin/reset-swim-tests" data-confirm="Reset all swim tests to not passed?">Reset Swim Tests</button>
|
||||
</div>
|
||||
<div id="status" class="status" style="margin-top: 10px;">Ready.</div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const THEME_KEY = "csc-theme";
|
||||
|
||||
function getPreferredTheme() {
|
||||
const saved = window.localStorage.getItem(THEME_KEY);
|
||||
if (saved === "dark" || saved === "light") {
|
||||
return saved;
|
||||
}
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
document.body.setAttribute("data-theme", theme);
|
||||
const lightBtn = document.getElementById("themeLightBtn");
|
||||
const darkBtn = document.getElementById("themeDarkBtn");
|
||||
if (lightBtn && darkBtn) {
|
||||
lightBtn.classList.toggle("active", theme === "light");
|
||||
darkBtn.classList.toggle("active", theme === "dark");
|
||||
lightBtn.setAttribute("aria-pressed", String(theme === "light"));
|
||||
darkBtn.setAttribute("aria-pressed", String(theme === "dark"));
|
||||
}
|
||||
}
|
||||
|
||||
function setTheme(theme) {
|
||||
window.localStorage.setItem(THEME_KEY, theme);
|
||||
applyTheme(theme);
|
||||
}
|
||||
|
||||
applyTheme(getPreferredTheme());
|
||||
|
||||
const familyForm = document.getElementById("familyForm");
|
||||
const memberForm = document.getElementById("memberForm");
|
||||
const memberFamily = document.getElementById("memberFamily");
|
||||
const deleteFamilySelect = document.getElementById("deleteFamilySelect");
|
||||
const deleteFamilyBtn = document.getElementById("deleteFamilyBtn");
|
||||
const statusBox = document.getElementById("status");
|
||||
const openFirstFamily = document.getElementById("openFirstFamily");
|
||||
const themeLightBtn = document.getElementById("themeLightBtn");
|
||||
const themeDarkBtn = document.getElementById("themeDarkBtn");
|
||||
|
||||
function setStatus(message) {
|
||||
const stamp = new Date().toLocaleTimeString();
|
||||
statusBox.textContent = `${stamp}: ${message}`;
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...options,
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || payload.message || "Request failed");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function loadFamiliesForSelect() {
|
||||
const payload = await api("/api/families");
|
||||
const families = payload.items || [];
|
||||
|
||||
memberFamily.innerHTML = "";
|
||||
deleteFamilySelect.innerHTML = "";
|
||||
families.forEach((family) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(family.id);
|
||||
option.textContent = `${family.familyName} Family`;
|
||||
memberFamily.append(option);
|
||||
|
||||
const deleteOption = document.createElement("option");
|
||||
deleteOption.value = String(family.id);
|
||||
deleteOption.textContent = `${family.familyName} Family`;
|
||||
deleteFamilySelect.append(deleteOption);
|
||||
});
|
||||
|
||||
if (families.length > 0) {
|
||||
openFirstFamily.href = `/family/${families[0].id}`;
|
||||
memberFamily.disabled = false;
|
||||
deleteFamilySelect.disabled = false;
|
||||
deleteFamilyBtn.disabled = false;
|
||||
} else {
|
||||
openFirstFamily.href = "/";
|
||||
memberFamily.disabled = true;
|
||||
deleteFamilySelect.disabled = true;
|
||||
deleteFamilyBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
familyForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const familyName = document.getElementById("familyName").value.trim();
|
||||
const primaryPhone = document.getElementById("familyPhone").value.trim();
|
||||
|
||||
try {
|
||||
await api("/api/families", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ familyName, primaryPhone }),
|
||||
});
|
||||
familyForm.reset();
|
||||
await loadFamiliesForSelect();
|
||||
setStatus("Family added.");
|
||||
} catch (error) {
|
||||
setStatus(`Error: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
memberForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!memberFamily.value) {
|
||||
setStatus("Error: Add a family before adding members.");
|
||||
return;
|
||||
}
|
||||
const firstName = document.getElementById("memberFirst").value.trim();
|
||||
const lastName = document.getElementById("memberLast").value.trim();
|
||||
const age = Number.parseInt(document.getElementById("memberAge").value, 10);
|
||||
const familyId = Number.parseInt(memberFamily.value, 10);
|
||||
|
||||
try {
|
||||
await api("/api/members", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ firstName, lastName, age, familyId }),
|
||||
});
|
||||
memberForm.reset();
|
||||
setStatus("Member added to family.");
|
||||
} catch (error) {
|
||||
setStatus(`Error: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
deleteFamilyBtn.addEventListener("click", async () => {
|
||||
const selectedId = Number.parseInt(deleteFamilySelect.value, 10);
|
||||
if (!Number.isInteger(selectedId)) {
|
||||
setStatus("Error: Select a family to delete.");
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedLabel = deleteFamilySelect.options[deleteFamilySelect.selectedIndex]?.textContent || "this family";
|
||||
const confirmed = window.confirm(
|
||||
`Delete ${selectedLabel} and all members in it? This cannot be undone.`
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteFamilyBtn.disabled = true;
|
||||
try {
|
||||
const payload = await api(`/api/families/${selectedId}`, { method: "DELETE" });
|
||||
await loadFamiliesForSelect();
|
||||
setStatus(payload.message || "Family deleted.");
|
||||
} catch (error) {
|
||||
setStatus(`Error: ${error.message}`);
|
||||
} finally {
|
||||
deleteFamilyBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-action]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const path = button.getAttribute("data-action");
|
||||
const confirmText = button.getAttribute("data-confirm");
|
||||
if (confirmText && !window.confirm(confirmText)) {
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
try {
|
||||
const payload = await api(path, { method: "POST" });
|
||||
setStatus(payload.message || "Action completed.");
|
||||
if (path === "/api/admin/cleanup-orphan-members") {
|
||||
await loadFamiliesForSelect();
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus(`Error: ${error.message}`);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
themeLightBtn.addEventListener("click", () => setTheme("light"));
|
||||
themeDarkBtn.addEventListener("click", () => setTheme("dark"));
|
||||
|
||||
loadFamiliesForSelect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
pool_checkin.sqlite3
Normal file
BIN
pool_checkin.sqlite3
Normal file
Binary file not shown.
5
pyvenv.cfg
Normal file
5
pyvenv.cfg
Normal file
@@ -0,0 +1,5 @@
|
||||
home = C:\Users\Lockeshor\AppData\Local\Programs\Python\Python312
|
||||
include-system-site-packages = false
|
||||
version = 3.12.4
|
||||
executable = C:\Users\Lockeshor\AppData\Local\Programs\Python\Python312\python.exe
|
||||
command = C:\Users\Lockeshor\AppData\Local\Programs\Python\Python312\python.exe -m venv c:\Users\Lockeshor\Documents\Code\csc-checkin\csc-checkin.venv
|
||||
Reference in New Issue
Block a user