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